@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tineira
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tineira/apc-web
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Agent Pointed Context (APC) for an app's page, in the browser: the person drags their agent's mark onto anything marked with `data-focus` (or taps it, on a phone) and the agent gets that as its focus. With the agent bar `<apc-agents>` (Revoke, Unlink, paths), the pin badge `<apc-pin>`, presence flashes, view reporting and the Direct agents list `<apc-direct-agents>`. Browser code with no Node imports; ESM for any bundler. Docs: https://apc.tineira.com/docs
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @tineira/apc-web@next @tineira/apc-protocol@next
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Pre-releases go out on the `next` tag while the standard is 0.x.
|
|
12
|
+
|
|
13
|
+
## Example
|
|
14
|
+
|
|
15
|
+
Mark what can be pointed at, and let the person pin their agent to it:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { focusAttrs } from "@tineira/apc-protocol";
|
|
19
|
+
import { defineAgentBar, enablePinning } from "@tineira/apc-web";
|
|
20
|
+
|
|
21
|
+
<tr {...focusAttrs({ kind: "customer", id: "cus_0042" })}>…</tr>
|
|
22
|
+
<g {...focusAttrs({ kind: "figure", params: { key: "active_customers", month: "2026-03" } })}>…</g>
|
|
23
|
+
|
|
24
|
+
defineAgentBar(); // <apc-agents> shows the person's agents, with Revoke
|
|
25
|
+
const stop = enablePinning({ onPin: (grantId, target) => savePin(grantId, target) });
|
|
26
|
+
```
|
|
27
|
+
|
|
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
|
+
|
|
30
|
+
Source, the worked examples (Pulse and Signals) and the changelog: https://github.com/tineira/agent-pointed-context. MIT.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent bar (TIN-394): the standard place, top right of every APC app, where the agents present
|
|
3
|
+
* in this app show. Each agent is its mark (logo; name as tooltip) and its state; a dot says where it
|
|
4
|
+
* points (click: go there), × removes the pin, ! warns of a shared URL. Dragging a mark onto a target
|
|
5
|
+
* points the agent within the page: the marks carry data-apc-mark and data-grant, the hooks
|
|
6
|
+
* enablePinning drags by. ⊘ revokes the agent (APC 0.2, TIN-577): a first click asks "Revoke?", a
|
|
7
|
+
* second within a few seconds emits apc-revoke, and the app cuts the agent's grant at once, whichever
|
|
8
|
+
* way it came (its own URL or a dock).
|
|
9
|
+
*
|
|
10
|
+
* A custom element in light DOM, not shadow DOM: enablePinning finds marks by event target, and the
|
|
11
|
+
* app's own fonts and theme apply. Its styles are injected once and scoped by class prefix; the app
|
|
12
|
+
* themes it through --apc-* custom properties. Framework-agnostic: set `agents`, listen for events.
|
|
13
|
+
*
|
|
14
|
+
* The bar's rule (APC 0.4, TIN-578):
|
|
15
|
+
* - Each agent shows which path it came by (`paths`): directly, via a dock, or both when the same agent
|
|
16
|
+
* is linked (apc_link), with Unlink (event apc-unlink).
|
|
17
|
+
* - With any agent that came directly, the bar is always shown in full: the app is the only place the
|
|
18
|
+
* person sees and points that agent. When every agent came through a dock, the person may collapse it
|
|
19
|
+
* to a small indicator of who is reading (the dock has the marks); it is never hidden. The pins on the
|
|
20
|
+
* page (`<apc-pin>`) stay visible and draggable either way.
|
|
21
|
+
* - On mobile (APC 0.6, TIN-608; a viewport that matches APC_MOBILE_MEDIA: a coarse pointer or a narrow
|
|
22
|
+
* width) the bar is the person's only way to point an agent, so it is never collapsed, whatever the
|
|
23
|
+
* agents' paths. It sits fixed at the bottom of the viewport, so it stays reachable while the page
|
|
24
|
+
* scrolls, and reserves its height at the bottom of the page (--apc-bar-height on <html>, plus
|
|
25
|
+
* padding-bottom on <body>) so it covers no content; an app's own fixed things at the bottom can
|
|
26
|
+
* sit above it with calc(... + var(--apc-bar-height, 0px)). Its marks, pin dots, × and ⊘ are finger
|
|
27
|
+
* sized. Tapping a mark arms the agent (tap to pin, enablePinning); the armed mark is outlined.
|
|
28
|
+
* - Offering the dock to a Direct user: a quiet hint, "Use this agent across apps with the dock", shown
|
|
29
|
+
* while an agent that came only directly is connected, until the person dismisses or follows it (once
|
|
30
|
+
* per browser). On by default; an app turns it off with `dockHint = null` or `dock-hint="off"`.
|
|
31
|
+
*/
|
|
32
|
+
import { type ApcProfile } from "@tineira/apc-protocol";
|
|
33
|
+
export type AgentState = "off" | "idle" | "waiting" | "busy" | "seen";
|
|
34
|
+
export interface BarAgent {
|
|
35
|
+
/** The app's id for the agent (its grant): what pins name. */
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
/** An image source for the agent's mark (a data URI or URL), unless the app draws it (renderIcon). */
|
|
39
|
+
icon?: string | null;
|
|
40
|
+
state: AgentState;
|
|
41
|
+
/** Where it points: in words, whether it read it, and whether that target is on this screen. */
|
|
42
|
+
pin?: {
|
|
43
|
+
label: string;
|
|
44
|
+
seen: boolean;
|
|
45
|
+
here: boolean;
|
|
46
|
+
} | null;
|
|
47
|
+
/** A note that something needs the person (TIN-358: another agent uses this one's URL). */
|
|
48
|
+
warning?: string | null;
|
|
49
|
+
/** Limited to part of the app (a restricted grant): outlined. */
|
|
50
|
+
restricted?: boolean;
|
|
51
|
+
/** How it came: ["direct"], ["dock"], or both when the same agent is linked (apc_link). Left out: not shown, and the bar never collapses. */
|
|
52
|
+
paths?: ApcProfile[];
|
|
53
|
+
}
|
|
54
|
+
/** Events, all with detail { id }: open (go to the pin), unpin, warning (open what the warning is about), revoke (cut the agent from the app, both paths when linked), unlink (show a linked agent as two again). */
|
|
55
|
+
export type AgentBarEvent = "apc-open" | "apc-unpin" | "apc-warning" | "apc-revoke" | "apc-unlink";
|
|
56
|
+
/** How long the first click on ⊘ waits for the second before it lets go. */
|
|
57
|
+
export declare const REVOKE_CONFIRM_MS = 4000;
|
|
58
|
+
/**
|
|
59
|
+
* The one pin a bar entry shows for an agent linked across both paths (apc_link), which holds two
|
|
60
|
+
* grants and so up to two pins: neither hidden. One pin is itself; two read "A and B" (the same
|
|
61
|
+
* target once), unread until both are read, and "here" only when both targets are on screen (so the
|
|
62
|
+
* dot still opens the one that is not). No pin at all is null.
|
|
63
|
+
*/
|
|
64
|
+
export declare function mergePins<P extends {
|
|
65
|
+
label: string;
|
|
66
|
+
seen: boolean;
|
|
67
|
+
here: boolean;
|
|
68
|
+
}>(pins: ReadonlyArray<P | null | undefined>): P | null;
|
|
69
|
+
/** The hint that offers the dock to a Direct user, and where it leads. */
|
|
70
|
+
export declare const DOCK_HINT: {
|
|
71
|
+
readonly text: "Use this agent across apps with the dock";
|
|
72
|
+
readonly url: "https://apc.tineira.com/dock";
|
|
73
|
+
};
|
|
74
|
+
/** 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
|
+
text: string;
|
|
77
|
+
title: string;
|
|
78
|
+
} | null;
|
|
79
|
+
/** 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. */
|
|
80
|
+
export declare function isMobileViewport(): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* The bar may collapse only when every agent came through a dock: an agent that came directly keeps it in
|
|
83
|
+
* full. On mobile (APC 0.6) never: the bar is the only place the person can point an agent from.
|
|
84
|
+
*/
|
|
85
|
+
export declare function barCanCollapse(agents: readonly BarAgent[], mobile?: boolean): boolean;
|
|
86
|
+
/** Who is reading, for the collapsed indicator: the agents that connected. */
|
|
87
|
+
export declare function readingSummary(agents: readonly BarAgent[]): string;
|
|
88
|
+
/** 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
|
+
export declare function showDockHint(agents: readonly BarAgent[], options: {
|
|
90
|
+
enabled: boolean;
|
|
91
|
+
dismissed: boolean;
|
|
92
|
+
}): boolean;
|
|
93
|
+
/** 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
|
+
mark: string;
|
|
96
|
+
dot: string | null;
|
|
97
|
+
revoke: string;
|
|
98
|
+
revokeArmed: string;
|
|
99
|
+
};
|
|
100
|
+
/** The element's constructor, defined only where custom elements exist (a browser), so importing this module in Node is safe. */
|
|
101
|
+
export declare function defineAgentBar(tag?: string): void;
|
|
102
|
+
/** The element's type, for apps that hold a reference to it. */
|
|
103
|
+
export interface ApcAgentsElement extends HTMLElement {
|
|
104
|
+
agents: BarAgent[];
|
|
105
|
+
renderIcon: ((agent: BarAgent, into: HTMLElement) => void) | null;
|
|
106
|
+
dockHint: {
|
|
107
|
+
text: string;
|
|
108
|
+
url: string;
|
|
109
|
+
} | null;
|
|
110
|
+
}
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent bar (TIN-394): the standard place, top right of every APC app, where the agents present
|
|
3
|
+
* in this app show. Each agent is its mark (logo; name as tooltip) and its state; a dot says where it
|
|
4
|
+
* points (click: go there), × removes the pin, ! warns of a shared URL. Dragging a mark onto a target
|
|
5
|
+
* points the agent within the page: the marks carry data-apc-mark and data-grant, the hooks
|
|
6
|
+
* enablePinning drags by. ⊘ revokes the agent (APC 0.2, TIN-577): a first click asks "Revoke?", a
|
|
7
|
+
* second within a few seconds emits apc-revoke, and the app cuts the agent's grant at once, whichever
|
|
8
|
+
* way it came (its own URL or a dock).
|
|
9
|
+
*
|
|
10
|
+
* A custom element in light DOM, not shadow DOM: enablePinning finds marks by event target, and the
|
|
11
|
+
* app's own fonts and theme apply. Its styles are injected once and scoped by class prefix; the app
|
|
12
|
+
* themes it through --apc-* custom properties. Framework-agnostic: set `agents`, listen for events.
|
|
13
|
+
*
|
|
14
|
+
* The bar's rule (APC 0.4, TIN-578):
|
|
15
|
+
* - Each agent shows which path it came by (`paths`): directly, via a dock, or both when the same agent
|
|
16
|
+
* is linked (apc_link), with Unlink (event apc-unlink).
|
|
17
|
+
* - With any agent that came directly, the bar is always shown in full: the app is the only place the
|
|
18
|
+
* person sees and points that agent. When every agent came through a dock, the person may collapse it
|
|
19
|
+
* to a small indicator of who is reading (the dock has the marks); it is never hidden. The pins on the
|
|
20
|
+
* page (`<apc-pin>`) stay visible and draggable either way.
|
|
21
|
+
* - On mobile (APC 0.6, TIN-608; a viewport that matches APC_MOBILE_MEDIA: a coarse pointer or a narrow
|
|
22
|
+
* width) the bar is the person's only way to point an agent, so it is never collapsed, whatever the
|
|
23
|
+
* agents' paths. It sits fixed at the bottom of the viewport, so it stays reachable while the page
|
|
24
|
+
* scrolls, and reserves its height at the bottom of the page (--apc-bar-height on <html>, plus
|
|
25
|
+
* padding-bottom on <body>) so it covers no content; an app's own fixed things at the bottom can
|
|
26
|
+
* sit above it with calc(... + var(--apc-bar-height, 0px)). Its marks, pin dots, × and ⊘ are finger
|
|
27
|
+
* sized. Tapping a mark arms the agent (tap to pin, enablePinning); the armed mark is outlined.
|
|
28
|
+
* - Offering the dock to a Direct user: a quiet hint, "Use this agent across apps with the dock", shown
|
|
29
|
+
* while an agent that came only directly is connected, until the person dismisses or follows it (once
|
|
30
|
+
* per browser). On by default; an app turns it off with `dockHint = null` or `dock-hint="off"`.
|
|
31
|
+
*/
|
|
32
|
+
import { APC_MOBILE_MEDIA } from "@tineira/apc-protocol";
|
|
33
|
+
import { APC_ARMED_EVENT } from "./pinning.js";
|
|
34
|
+
/** How long the first click on ⊘ waits for the second before it lets go. */
|
|
35
|
+
export const REVOKE_CONFIRM_MS = 4000;
|
|
36
|
+
/**
|
|
37
|
+
* The one pin a bar entry shows for an agent linked across both paths (apc_link), which holds two
|
|
38
|
+
* grants and so up to two pins: neither hidden. One pin is itself; two read "A and B" (the same
|
|
39
|
+
* target once), unread until both are read, and "here" only when both targets are on screen (so the
|
|
40
|
+
* dot still opens the one that is not). No pin at all is null.
|
|
41
|
+
*/
|
|
42
|
+
export function mergePins(pins) {
|
|
43
|
+
const some = pins.filter((p) => !!p);
|
|
44
|
+
if (some.length < 2)
|
|
45
|
+
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
|
+
}
|
|
48
|
+
/** 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
|
+
const COLLAPSED_KEY = "apc-agents.collapsed";
|
|
51
|
+
const HINT_KEY = "apc-agents.dock-hint";
|
|
52
|
+
/** 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
|
+
if (!paths?.length)
|
|
55
|
+
return null;
|
|
56
|
+
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
|
+
}
|
|
60
|
+
/** 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
|
+
export function isMobileViewport() {
|
|
62
|
+
return typeof matchMedia === "function" && matchMedia(APC_MOBILE_MEDIA).matches;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The bar may collapse only when every agent came through a dock: an agent that came directly keeps it in
|
|
66
|
+
* full. On mobile (APC 0.6) never: the bar is the only place the person can point an agent from.
|
|
67
|
+
*/
|
|
68
|
+
export function barCanCollapse(agents, mobile = false) {
|
|
69
|
+
return !mobile && agents.length > 0 && agents.every((a) => !!a.paths?.length && !a.paths.includes("direct"));
|
|
70
|
+
}
|
|
71
|
+
/** Who is reading, for the collapsed indicator: the agents that connected. */
|
|
72
|
+
export function readingSummary(agents) {
|
|
73
|
+
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`;
|
|
79
|
+
}
|
|
80
|
+
/** 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
|
+
export function showDockHint(agents, options) {
|
|
82
|
+
return options.enabled && !options.dismissed && agents.some((a) => a.paths?.length === 1 && a.paths[0] === "direct" && a.state !== "off");
|
|
83
|
+
}
|
|
84
|
+
function stored(key) {
|
|
85
|
+
try {
|
|
86
|
+
return localStorage.getItem(key);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function store(key, value) {
|
|
93
|
+
try {
|
|
94
|
+
if (value === null)
|
|
95
|
+
localStorage.removeItem(key);
|
|
96
|
+
else
|
|
97
|
+
localStorage.setItem(key, value);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// private mode or storage off: for this page only
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const STYLE_ID = "apc-agents-style";
|
|
104
|
+
const CSS = `
|
|
105
|
+
apc-agents { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
|
|
106
|
+
.apc-agents__agent { position: relative; display: inline-flex; align-items: center; gap: 6px; }
|
|
107
|
+
.apc-agents__agent[hidden] { display: none; }
|
|
108
|
+
.apc-agents__mark { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border-radius: 8px; cursor: grab; touch-action: none;
|
|
109
|
+
background: var(--apc-chip, #ffffff); border: 1px solid var(--apc-line, #d6dce5); color: var(--apc-ink, #141a24); }
|
|
110
|
+
.apc-agents__mark:active { cursor: grabbing; }
|
|
111
|
+
.apc-agents__mark img, .apc-agents__mark svg { width: 22px; height: 22px; display: block; pointer-events: none; }
|
|
112
|
+
.apc-agents__mark--restricted { outline: 2px solid var(--apc-accent, #b85209); outline-offset: 1px; }
|
|
113
|
+
.apc-agents__mark--off img, .apc-agents__mark--off svg { filter: grayscale(1); opacity: 0.45; }
|
|
114
|
+
.apc-agents__mark--waiting img, .apc-agents__mark--waiting svg { animation: apc-agents-float 3s ease-in-out infinite; }
|
|
115
|
+
.apc-agents__mark--busy img, .apc-agents__mark--busy svg { animation: apc-agents-pulse 0.6s ease-in-out infinite; }
|
|
116
|
+
.apc-agents__mark--seen img, .apc-agents__mark--seen svg { animation: apc-agents-bounce 0.9s ease-out 1; }
|
|
117
|
+
.apc-agents__warn { position: absolute; top: -5px; left: 22px; width: 14px; height: 14px; padding: 0; border: 0; border-radius: 50%; cursor: pointer;
|
|
118
|
+
background: var(--apc-warn, #c0392b); color: #fff; font: 700 10px/14px system-ui, sans-serif; text-align: center; }
|
|
119
|
+
.apc-agents__pin { display: inline-flex; align-items: center; gap: 2px; }
|
|
120
|
+
.apc-agents__dot { width: 10px; height: 10px; padding: 0; border: 0; border-radius: 50%; cursor: default; background: var(--apc-accent, #b85209); }
|
|
121
|
+
.apc-agents__dot--seen { background: var(--apc-ok, #1f7a4d); }
|
|
122
|
+
.apc-agents__dot--away { cursor: pointer; box-shadow: 0 0 0 3px var(--apc-accent-soft, rgb(184 82 9 / 0.2)); }
|
|
123
|
+
.apc-agents__unpin { border: 0; background: none; padding: 0 3px; font: 14px/1 system-ui, sans-serif; color: inherit; cursor: pointer; opacity: 0.7; }
|
|
124
|
+
.apc-agents__unpin:hover { opacity: 1; }
|
|
125
|
+
.apc-agents__revoke { border: 0; background: none; padding: 0 2px; font: 13px/1 system-ui, sans-serif; color: inherit; cursor: pointer; opacity: 0.45; }
|
|
126
|
+
.apc-agents__revoke:hover, .apc-agents__revoke:focus-visible { opacity: 1; color: var(--apc-warn, #c0392b); }
|
|
127
|
+
.apc-agents__revoke.apc-agents__revoke--armed { opacity: 1; padding: 2px 6px; border-radius: 6px; font-weight: 600; font-size: 12px; color: #fff; background: var(--apc-warn, #c0392b); }
|
|
128
|
+
.apc-agents__path { font: 600 10px/1 system-ui, sans-serif; letter-spacing: 0.02em; white-space: nowrap; color: var(--apc-muted, #5b6575); }
|
|
129
|
+
.apc-agents__unlink, .apc-agents__collapse, .apc-agents__hint button { border: 0; background: none; padding: 0 2px; font: 11px/1 system-ui, sans-serif; color: var(--apc-muted, #5b6575); cursor: pointer; text-decoration: underline dotted; }
|
|
130
|
+
.apc-agents__unlink:hover, .apc-agents__collapse:hover, .apc-agents__hint button:hover { color: var(--apc-ink, #141a24); }
|
|
131
|
+
.apc-agents__collapse { text-decoration: none; font-size: 13px; opacity: 0.6; }
|
|
132
|
+
.apc-agents__mini { display: inline-flex; align-items: center; gap: 4px; height: 26px; padding: 0 8px 0 4px; border-radius: 999px; cursor: pointer;
|
|
133
|
+
border: 1px solid var(--apc-line, #d6dce5); background: var(--apc-chip, #ffffff); color: var(--apc-ink, #141a24); font: 600 11px/1 system-ui, sans-serif; }
|
|
134
|
+
.apc-agents__mini-icon { display: grid; place-items: center; width: 16px; height: 16px; }
|
|
135
|
+
.apc-agents__mini-icon img, .apc-agents__mini-icon svg { width: 16px; height: 16px; display: block; }
|
|
136
|
+
.apc-agents__hint { display: inline-flex; align-items: center; gap: 2px; white-space: nowrap; font: 11px/1.2 system-ui, sans-serif; color: var(--apc-muted, #5b6575); }
|
|
137
|
+
.apc-agents__hint a { color: inherit; }
|
|
138
|
+
.apc-agents__hint a:hover { color: var(--apc-ink, #141a24); }
|
|
139
|
+
.apc-agents__mark[aria-pressed="true"] { outline: 3px solid var(--apc-accent, #b85209); outline-offset: 2px; }
|
|
140
|
+
html.apc-bar-fixed body { padding-bottom: var(--apc-bar-height, 0px); }
|
|
141
|
+
@media ${APC_MOBILE_MEDIA} {
|
|
142
|
+
apc-agents { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483000; box-sizing: border-box; display: flex; justify-content: center; gap: 4px 14px; width: auto; max-height: 40vh; overflow-y: auto; margin: 0; padding: 8px 12px calc(8px + env(safe-area-inset-bottom, 0px));
|
|
143
|
+
background: var(--apc-bar-bg, var(--apc-chip, #ffffff)); border-top: 1px solid var(--apc-line, #d6dce5); box-shadow: 0 -2px 10px rgb(0 0 0 / 0.08); }
|
|
144
|
+
apc-agents:empty { display: none; }
|
|
145
|
+
.apc-agents__agent { flex-wrap: wrap; row-gap: 0; }
|
|
146
|
+
.apc-agents__mark { width: 44px; height: 44px; border-radius: 10px; }
|
|
147
|
+
.apc-agents__mark img, .apc-agents__mark svg { width: 28px; height: 28px; }
|
|
148
|
+
.apc-agents__dot { position: relative; width: 14px; height: 14px; margin: 0 6px; }
|
|
149
|
+
.apc-agents__dot::after, .apc-agents__warn::after { content: ""; position: absolute; inset: -14px; }
|
|
150
|
+
.apc-agents__warn { top: -8px; left: 30px; width: 20px; height: 20px; font-size: 13px; line-height: 20px; }
|
|
151
|
+
.apc-agents__unpin { min-width: 44px; min-height: 44px; padding: 0 12px; font-size: 22px; }
|
|
152
|
+
.apc-agents__revoke { min-width: 44px; min-height: 44px; padding: 0 10px; font-size: 17px; }
|
|
153
|
+
.apc-agents__revoke.apc-agents__revoke--armed { padding: 0 12px; font-size: 14px; }
|
|
154
|
+
.apc-agents__unlink { min-height: 44px; padding: 0 8px; font-size: 13px; }
|
|
155
|
+
.apc-agents__path { font-size: 11px; }
|
|
156
|
+
.apc-agents__hint { display: none; }
|
|
157
|
+
}
|
|
158
|
+
@keyframes apc-agents-float { 0%, 100% { transform: translateY(1.5px); } 50% { transform: translateY(-2.5px); } }
|
|
159
|
+
@keyframes apc-agents-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.18); } }
|
|
160
|
+
@keyframes apc-agents-bounce { 0% { transform: translateY(0); } 25% { transform: translateY(-6px); } 50% { transform: translateY(0); } 70% { transform: translateY(-2px); } 100% { transform: translateY(0); } }
|
|
161
|
+
@media (prefers-reduced-motion: reduce) { .apc-agents__mark img, .apc-agents__mark svg { animation: none !important; } }
|
|
162
|
+
`;
|
|
163
|
+
/** A small robot, for an agent with no icon. */
|
|
164
|
+
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
|
+
/** 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.`;
|
|
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}.`;
|
|
171
|
+
if (!a.pin)
|
|
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 };
|
|
175
|
+
}
|
|
176
|
+
/** 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
|
+
const reserved = new Map();
|
|
178
|
+
function reserveBottom(el, height) {
|
|
179
|
+
if (height > 0)
|
|
180
|
+
reserved.set(el, height);
|
|
181
|
+
else
|
|
182
|
+
reserved.delete(el);
|
|
183
|
+
const max = Math.max(0, ...reserved.values());
|
|
184
|
+
const html = document.documentElement;
|
|
185
|
+
if (max > 0) {
|
|
186
|
+
html.style.setProperty("--apc-bar-height", `${max}px`);
|
|
187
|
+
html.classList.add("apc-bar-fixed");
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
html.style.removeProperty("--apc-bar-height");
|
|
191
|
+
html.classList.remove("apc-bar-fixed");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** The element's constructor, defined only where custom elements exist (a browser), so importing this module in Node is safe. */
|
|
195
|
+
export function defineAgentBar(tag = "apc-agents") {
|
|
196
|
+
if (typeof customElements === "undefined" || customElements.get(tag))
|
|
197
|
+
return;
|
|
198
|
+
class ApcAgents extends HTMLElement {
|
|
199
|
+
list = [];
|
|
200
|
+
nodes = new Map();
|
|
201
|
+
/** Agents whose ⊘ was clicked once, with the timer that lets go. */
|
|
202
|
+
armed = new Map();
|
|
203
|
+
/** The app draws an agent's icon into this element itself (for example with its own logos). Called on every update. */
|
|
204
|
+
renderIcon = null;
|
|
205
|
+
hint = { ...DOCK_HINT };
|
|
206
|
+
/** The collapsed indicator's icons, kept per agent like the marks' (an app's renderIcon may keep state in them). */
|
|
207
|
+
miniIcons = new Map();
|
|
208
|
+
get agents() {
|
|
209
|
+
return this.list;
|
|
210
|
+
}
|
|
211
|
+
set agents(next) {
|
|
212
|
+
this.list = next;
|
|
213
|
+
this.render();
|
|
214
|
+
}
|
|
215
|
+
/** The hint that offers the dock to a Direct user; null turns it off. */
|
|
216
|
+
get dockHint() {
|
|
217
|
+
return this.getAttribute("dock-hint") === "off" ? null : this.hint;
|
|
218
|
+
}
|
|
219
|
+
set dockHint(next) {
|
|
220
|
+
this.hint = next;
|
|
221
|
+
this.render();
|
|
222
|
+
}
|
|
223
|
+
/** Mobile (APC 0.6): the viewport is a phone's or a touch one's; the bar then never collapses. */
|
|
224
|
+
media = null;
|
|
225
|
+
resizer = null;
|
|
226
|
+
mobile() {
|
|
227
|
+
return !!this.media?.matches;
|
|
228
|
+
}
|
|
229
|
+
onMedia = () => {
|
|
230
|
+
this.render();
|
|
231
|
+
this.reserve();
|
|
232
|
+
};
|
|
233
|
+
onArmed = () => {
|
|
234
|
+
for (const node of this.nodes.values())
|
|
235
|
+
node.key = "";
|
|
236
|
+
this.render();
|
|
237
|
+
};
|
|
238
|
+
/** Tell the page how much room the fixed bar takes at the bottom, or that it takes none. */
|
|
239
|
+
reserve() {
|
|
240
|
+
reserveBottom(this, this.mobile() ? this.offsetHeight : 0);
|
|
241
|
+
}
|
|
242
|
+
collapsed() {
|
|
243
|
+
return barCanCollapse(this.list, this.mobile()) && stored(COLLAPSED_KEY) === "1";
|
|
244
|
+
}
|
|
245
|
+
disconnectedCallback() {
|
|
246
|
+
this.media?.removeEventListener("change", this.onMedia);
|
|
247
|
+
this.media = null;
|
|
248
|
+
this.resizer?.disconnect();
|
|
249
|
+
this.resizer = null;
|
|
250
|
+
window.removeEventListener(APC_ARMED_EVENT, this.onArmed);
|
|
251
|
+
reserveBottom(this, 0);
|
|
252
|
+
}
|
|
253
|
+
connectedCallback() {
|
|
254
|
+
if (typeof matchMedia === "function") {
|
|
255
|
+
this.media = matchMedia(APC_MOBILE_MEDIA);
|
|
256
|
+
this.media.addEventListener("change", this.onMedia);
|
|
257
|
+
}
|
|
258
|
+
window.addEventListener(APC_ARMED_EVENT, this.onArmed);
|
|
259
|
+
if (typeof ResizeObserver === "function") {
|
|
260
|
+
this.resizer = new ResizeObserver(() => this.reserve());
|
|
261
|
+
this.resizer.observe(this);
|
|
262
|
+
}
|
|
263
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
264
|
+
const style = document.createElement("style");
|
|
265
|
+
style.id = STYLE_ID;
|
|
266
|
+
style.textContent = CSS;
|
|
267
|
+
document.head.append(style);
|
|
268
|
+
}
|
|
269
|
+
if (!this.hasAttribute("role"))
|
|
270
|
+
this.setAttribute("role", "toolbar");
|
|
271
|
+
if (!this.hasAttribute("aria-label"))
|
|
272
|
+
this.setAttribute("aria-label", "Agents");
|
|
273
|
+
this.render();
|
|
274
|
+
}
|
|
275
|
+
emit(type, id) {
|
|
276
|
+
this.dispatchEvent(new CustomEvent(type, { detail: { id }, bubbles: true }));
|
|
277
|
+
}
|
|
278
|
+
disarm(id, rerender = true) {
|
|
279
|
+
const timer = this.armed.get(id);
|
|
280
|
+
if (timer === undefined)
|
|
281
|
+
return;
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
this.armed.delete(id);
|
|
284
|
+
if (rerender)
|
|
285
|
+
this.render();
|
|
286
|
+
}
|
|
287
|
+
/** First click arms, second (within REVOKE_CONFIRM_MS) emits apc-revoke. */
|
|
288
|
+
revokeClicked(id) {
|
|
289
|
+
if (this.armed.has(id)) {
|
|
290
|
+
this.disarm(id);
|
|
291
|
+
this.emit("apc-revoke", id);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
this.armed.set(id, setTimeout(() => this.disarm(id), REVOKE_CONFIRM_MS));
|
|
295
|
+
this.render();
|
|
296
|
+
this.nodes.get(id)?.root.querySelector(".apc-agents__revoke")?.focus();
|
|
297
|
+
}
|
|
298
|
+
/** Keyed by agent: an agent's element (and the icon the app drew in it) lives as long as the agent. */
|
|
299
|
+
render() {
|
|
300
|
+
if (!this.isConnected)
|
|
301
|
+
return;
|
|
302
|
+
const collapsed = this.collapsed();
|
|
303
|
+
const seen = new Set();
|
|
304
|
+
for (const a of this.list) {
|
|
305
|
+
seen.add(a.id);
|
|
306
|
+
let node = this.nodes.get(a.id);
|
|
307
|
+
if (!node) {
|
|
308
|
+
const root = document.createElement("span");
|
|
309
|
+
root.className = "apc-agents__agent";
|
|
310
|
+
const icon = document.createElement("span");
|
|
311
|
+
icon.className = "apc-agents__icon";
|
|
312
|
+
node = { root, icon, key: "" };
|
|
313
|
+
this.nodes.set(a.id, node);
|
|
314
|
+
}
|
|
315
|
+
// Collapsed, an agent's element is hidden, not removed: what the app drew in it stays.
|
|
316
|
+
node.root.hidden = collapsed;
|
|
317
|
+
this.append(node.root);
|
|
318
|
+
this.fill(node, a);
|
|
319
|
+
}
|
|
320
|
+
for (const [id, node] of this.nodes) {
|
|
321
|
+
if (seen.has(id))
|
|
322
|
+
continue;
|
|
323
|
+
node.root.remove();
|
|
324
|
+
this.nodes.delete(id);
|
|
325
|
+
this.miniIcons.delete(id);
|
|
326
|
+
this.disarm(id, false);
|
|
327
|
+
}
|
|
328
|
+
for (const old of this.querySelectorAll(":scope > .apc-agents__extra"))
|
|
329
|
+
old.remove();
|
|
330
|
+
if (collapsed)
|
|
331
|
+
this.append(this.mini());
|
|
332
|
+
else if (barCanCollapse(this.list, this.mobile())) {
|
|
333
|
+
const fold = document.createElement("button");
|
|
334
|
+
fold.type = "button";
|
|
335
|
+
fold.className = "apc-agents__collapse apc-agents__extra";
|
|
336
|
+
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");
|
|
339
|
+
fold.addEventListener("click", () => {
|
|
340
|
+
store(COLLAPSED_KEY, "1");
|
|
341
|
+
this.render();
|
|
342
|
+
});
|
|
343
|
+
this.append(fold);
|
|
344
|
+
}
|
|
345
|
+
const hint = this.dockHint;
|
|
346
|
+
if (hint && showDockHint(this.list, { enabled: true, dismissed: stored(HINT_KEY) === "dismissed" }))
|
|
347
|
+
this.append(this.hintNode(hint));
|
|
348
|
+
}
|
|
349
|
+
/** Collapsed: who is reading, as small icons and a count; a click shows the bar again. Never hidden. */
|
|
350
|
+
mini() {
|
|
351
|
+
const reading = this.list.filter((a) => a.state !== "off");
|
|
352
|
+
const button = document.createElement("button");
|
|
353
|
+
button.type = "button";
|
|
354
|
+
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.`);
|
|
358
|
+
for (const a of reading.slice(0, 4)) {
|
|
359
|
+
let icon = this.miniIcons.get(a.id);
|
|
360
|
+
if (!icon) {
|
|
361
|
+
icon = document.createElement("span");
|
|
362
|
+
icon.className = "apc-agents__mini-icon";
|
|
363
|
+
this.miniIcons.set(a.id, icon);
|
|
364
|
+
}
|
|
365
|
+
if (this.renderIcon)
|
|
366
|
+
this.renderIcon(a, icon);
|
|
367
|
+
else if (a.icon) {
|
|
368
|
+
const img = document.createElement("img");
|
|
369
|
+
img.src = a.icon;
|
|
370
|
+
img.alt = "";
|
|
371
|
+
icon.replaceChildren(img);
|
|
372
|
+
}
|
|
373
|
+
else
|
|
374
|
+
icon.innerHTML = BOT;
|
|
375
|
+
button.append(icon);
|
|
376
|
+
}
|
|
377
|
+
button.append(document.createTextNode(reading.length ? `${reading.length} reading` : "no agent reading"));
|
|
378
|
+
button.addEventListener("click", () => {
|
|
379
|
+
store(COLLAPSED_KEY, null);
|
|
380
|
+
this.render();
|
|
381
|
+
});
|
|
382
|
+
return button;
|
|
383
|
+
}
|
|
384
|
+
hintNode(hint) {
|
|
385
|
+
const box = document.createElement("span");
|
|
386
|
+
box.className = "apc-agents__hint apc-agents__extra";
|
|
387
|
+
const dismiss = () => {
|
|
388
|
+
store(HINT_KEY, "dismissed");
|
|
389
|
+
this.render();
|
|
390
|
+
};
|
|
391
|
+
const link = document.createElement("a");
|
|
392
|
+
link.href = hint.url;
|
|
393
|
+
link.target = "_blank";
|
|
394
|
+
link.rel = "noopener";
|
|
395
|
+
link.textContent = hint.text;
|
|
396
|
+
link.addEventListener("click", () => setTimeout(dismiss, 0));
|
|
397
|
+
const close = document.createElement("button");
|
|
398
|
+
close.type = "button";
|
|
399
|
+
close.textContent = "×";
|
|
400
|
+
close.setAttribute("aria-label", "Dismiss this hint");
|
|
401
|
+
close.addEventListener("click", dismiss);
|
|
402
|
+
box.append(link, close);
|
|
403
|
+
return box;
|
|
404
|
+
}
|
|
405
|
+
fill(node, a) {
|
|
406
|
+
const labels = agentLabels(a);
|
|
407
|
+
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]);
|
|
409
|
+
if (this.renderIcon)
|
|
410
|
+
this.renderIcon(a, node.icon);
|
|
411
|
+
else if (node.key === "" || JSON.stringify(a.icon ?? null) !== node.icon.dataset.src) {
|
|
412
|
+
node.icon.dataset.src = JSON.stringify(a.icon ?? null);
|
|
413
|
+
if (a.icon) {
|
|
414
|
+
const img = document.createElement("img");
|
|
415
|
+
img.src = a.icon;
|
|
416
|
+
img.alt = "";
|
|
417
|
+
node.icon.replaceChildren(img);
|
|
418
|
+
}
|
|
419
|
+
else
|
|
420
|
+
node.icon.innerHTML = BOT;
|
|
421
|
+
}
|
|
422
|
+
if (node.key === key)
|
|
423
|
+
return;
|
|
424
|
+
node.key = key;
|
|
425
|
+
const mark = document.createElement("button");
|
|
426
|
+
mark.type = "button";
|
|
427
|
+
mark.className = `apc-agents__mark apc-agents__mark--${a.state}${a.restricted ? " apc-agents__mark--restricted" : ""}`;
|
|
428
|
+
mark.dataset.apcMark = "";
|
|
429
|
+
mark.dataset.grant = a.id;
|
|
430
|
+
mark.title = labels.mark;
|
|
431
|
+
mark.setAttribute("aria-label", a.name);
|
|
432
|
+
if (document.documentElement.getAttribute("data-apc-armed") === a.id)
|
|
433
|
+
mark.setAttribute("aria-pressed", "true");
|
|
434
|
+
// The icon element moves into the new mark, so what the app drew in it stays.
|
|
435
|
+
mark.append(node.icon);
|
|
436
|
+
const parts = [mark];
|
|
437
|
+
if (a.warning) {
|
|
438
|
+
const warn = document.createElement("button");
|
|
439
|
+
warn.type = "button";
|
|
440
|
+
warn.className = "apc-agents__warn";
|
|
441
|
+
warn.textContent = "!";
|
|
442
|
+
warn.title = a.warning;
|
|
443
|
+
warn.setAttribute("aria-label", a.warning);
|
|
444
|
+
warn.addEventListener("click", () => this.emit("apc-warning", a.id));
|
|
445
|
+
parts.push(warn);
|
|
446
|
+
}
|
|
447
|
+
if (a.pin && labels.dot) {
|
|
448
|
+
const pin = document.createElement("span");
|
|
449
|
+
pin.className = "apc-agents__pin";
|
|
450
|
+
const dot = document.createElement("button");
|
|
451
|
+
dot.type = "button";
|
|
452
|
+
dot.className = `apc-agents__dot${a.pin.seen ? " apc-agents__dot--seen" : ""}${a.pin.here ? "" : " apc-agents__dot--away"}`;
|
|
453
|
+
dot.title = labels.dot;
|
|
454
|
+
dot.setAttribute("aria-label", `${a.name} pinned to ${a.pin.label}${a.pin.seen ? ", read" : ", not read yet"}`);
|
|
455
|
+
dot.addEventListener("click", () => !a.pin?.here && this.emit("apc-open", a.id));
|
|
456
|
+
const unpin = document.createElement("button");
|
|
457
|
+
unpin.type = "button";
|
|
458
|
+
unpin.className = "apc-agents__unpin";
|
|
459
|
+
unpin.textContent = "×";
|
|
460
|
+
unpin.setAttribute("aria-label", `Remove ${a.name}'s pin`);
|
|
461
|
+
unpin.addEventListener("click", (e) => e.isTrusted && this.emit("apc-unpin", a.id));
|
|
462
|
+
pin.append(dot, unpin);
|
|
463
|
+
parts.push(pin);
|
|
464
|
+
}
|
|
465
|
+
// Which path it came by; a linked agent can be shown as two again.
|
|
466
|
+
const path = pathLabel(a.paths);
|
|
467
|
+
if (path) {
|
|
468
|
+
const tag = document.createElement("span");
|
|
469
|
+
tag.className = "apc-agents__path";
|
|
470
|
+
tag.textContent = path.text;
|
|
471
|
+
tag.title = path.title;
|
|
472
|
+
parts.push(tag);
|
|
473
|
+
if (a.paths?.length === 2) {
|
|
474
|
+
const unlink = document.createElement("button");
|
|
475
|
+
unlink.type = "button";
|
|
476
|
+
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}`);
|
|
480
|
+
unlink.addEventListener("click", (e) => e.isTrusted && this.emit("apc-unlink", a.id));
|
|
481
|
+
parts.push(unlink);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// Revoke from the app: every agent has it, whichever way it came.
|
|
485
|
+
const revoke = document.createElement("button");
|
|
486
|
+
revoke.type = "button";
|
|
487
|
+
revoke.className = `apc-agents__revoke${armed ? " apc-agents__revoke--armed" : ""}`;
|
|
488
|
+
revoke.textContent = armed ? "Revoke?" : "⊘";
|
|
489
|
+
revoke.title = armed ? labels.revokeArmed : labels.revoke;
|
|
490
|
+
revoke.setAttribute("aria-label", armed ? labels.revokeArmed : `Revoke ${a.name}`);
|
|
491
|
+
revoke.addEventListener("click", (e) => e.isTrusted && this.revokeClicked(a.id));
|
|
492
|
+
revoke.addEventListener("keydown", (e) => e.key === "Escape" && this.disarm(a.id));
|
|
493
|
+
parts.push(revoke);
|
|
494
|
+
node.root.replaceChildren(...parts);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
customElements.define(tag, ApcAgents);
|
|
498
|
+
}
|