visual-comments 0.1.0 → 0.2.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.
Files changed (55) hide show
  1. package/README.md +19 -2
  2. package/dist/cli/index.d.ts +2 -0
  3. package/dist/cli/index.js +477 -0
  4. package/dist/core/presence.d.ts +13 -0
  5. package/dist/core/presence.js +25 -0
  6. package/dist/core/threads.svelte.d.ts +36 -2
  7. package/dist/core/threads.svelte.js +120 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/server/env.d.ts +78 -0
  10. package/dist/server/env.js +94 -0
  11. package/dist/server/index.d.ts +1 -0
  12. package/dist/server/index.js +10 -3
  13. package/dist/server/variables.d.ts +28 -0
  14. package/dist/server/variables.js +73 -0
  15. package/dist/shared/protocol.d.ts +4 -0
  16. package/dist/shared/protocol.js +4 -0
  17. package/dist/shared/types.d.ts +64 -0
  18. package/dist/sveltekit/CommentTools.svelte +98 -0
  19. package/dist/sveltekit/CommentTools.svelte.d.ts +6 -0
  20. package/dist/sveltekit/Comments.svelte +104 -0
  21. package/dist/sveltekit/Comments.svelte.d.ts +25 -0
  22. package/dist/sveltekit/Toolbar.svelte +64 -0
  23. package/dist/sveltekit/Toolbar.svelte.d.ts +13 -0
  24. package/dist/sveltekit/ToolbarButton.svelte +66 -0
  25. package/dist/sveltekit/ToolbarButton.svelte.d.ts +12 -0
  26. package/dist/sveltekit/context.d.ts +16 -0
  27. package/dist/sveltekit/context.js +9 -0
  28. package/dist/sveltekit/index.d.ts +6 -0
  29. package/dist/sveltekit/index.js +6 -0
  30. package/dist/ui/Account.svelte +45 -7
  31. package/dist/ui/App.svelte +12 -3
  32. package/dist/ui/Avatar.svelte +28 -6
  33. package/dist/ui/Avatar.svelte.d.ts +4 -5
  34. package/dist/ui/Connect.svelte +128 -0
  35. package/dist/ui/Connect.svelte.d.ts +6 -0
  36. package/dist/ui/Menu.svelte +5 -1
  37. package/dist/ui/Menu.svelte.d.ts +2 -0
  38. package/dist/ui/Overlay.svelte +15 -1
  39. package/dist/ui/Panel.svelte +141 -127
  40. package/dist/ui/Pin.svelte +22 -2
  41. package/dist/ui/Pin.svelte.d.ts +2 -0
  42. package/dist/ui/Presence.svelte +43 -0
  43. package/dist/ui/Presence.svelte.d.ts +8 -0
  44. package/dist/ui/ThreadCard.svelte +26 -2
  45. package/dist/ui/generated/styles.css +1 -1
  46. package/dist/ui/generated/styles.js +2 -2
  47. package/dist/ui/styles.css +21 -0
  48. package/dist/ui/text.d.ts +3 -1
  49. package/dist/ui/text.js +4 -0
  50. package/dist/visual-comments.svelte.d.ts +53 -4
  51. package/dist/visual-comments.svelte.js +154 -29
  52. package/dist/vite/index.d.ts +20 -0
  53. package/dist/vite/index.js +78 -0
  54. package/dist-widget/visual-comments.js +17 -10
  55. package/package.json +88 -66
@@ -0,0 +1,128 @@
1
+ <svelte:options css="injected" />
2
+
3
+ <!--
4
+ Approving an agent. `visual-comments connect` sends the person to
5
+ `#vc-connect=<id>`; Allow adds the agent to the space, under its own name.
6
+ Signed out, it offers to sign in first; the address keeps the request.
7
+ -->
8
+ <script lang="ts">
9
+ import type { Agent, AgentRequest } from '../shared/types.js';
10
+ import { useComments } from './context.js';
11
+
12
+ let { id }: { id: string } = $props();
13
+
14
+ const vc = useComments();
15
+ const space = $derived(vc.store.spaceName || 'this space');
16
+ const signedIn = $derived(Boolean(vc.viewer));
17
+ let request = $state<AgentRequest>();
18
+ let joined = $state<Agent>();
19
+ let failed = $state<string>();
20
+ let busy = $state(false);
21
+
22
+ $effect(() => {
23
+ if (!signedIn) return;
24
+ let current = true;
25
+ request = undefined;
26
+ failed = undefined;
27
+ vc.store.agentRequest(id).then(
28
+ (found) => { if (current) request = found; },
29
+ (error: Error) => {
30
+ if (!current) return;
31
+ failed = error.message;
32
+ // The sign-in may have lapsed since the page loaded; then this offers it again.
33
+ void vc.store.refresh();
34
+ }
35
+ );
36
+ return () => { current = false; };
37
+ });
38
+
39
+ async function answer(allow: boolean) {
40
+ busy = true;
41
+ failed = undefined;
42
+ try {
43
+ if (allow) joined = await vc.store.approveAgent(id);
44
+ else {
45
+ await vc.store.denyAgent(id);
46
+ vc.endConnect();
47
+ }
48
+ } catch (error) {
49
+ failed = (error as Error).message;
50
+ } finally {
51
+ busy = false;
52
+ }
53
+ }
54
+ </script>
55
+
56
+ <dialog
57
+ {@attach (node) => node.showModal()}
58
+ onclose={() => vc.endConnect()}
59
+ aria-labelledby="vc-connect-title"
60
+ class="vc-ui pointer-events-auto m-auto w-[360px] max-w-[calc(100vw-24px)] rounded-[12px] bg-white p-5 text-[13px] text-vc-ink shadow-[0_8px_40px_rgb(0_0_0/0.28)] outline-none backdrop:bg-black/30"
61
+ >
62
+ {#if !vc.viewer}
63
+ <h2 id="vc-connect-title" class="text-[15px] font-semibold">An agent wants to join</h2>
64
+ {#if vc.loading}
65
+ <p class="mt-2 text-vc-muted">Connecting…</p>
66
+ {:else}
67
+ <p class="mt-2 text-vc-muted">Sign in to see which agent asks, and to let it read and write comments here.</p>
68
+ <div class="mt-5 flex justify-end gap-2">
69
+ <form method="dialog"><button class="vc-dialog-button">Cancel</button></form>
70
+ <button type="button" class="vc-dialog-button vc-dialog-primary" onclick={() => vc.signIn()}>Sign in</button>
71
+ </div>
72
+ {/if}
73
+ {:else if joined}
74
+ <h2 id="vc-connect-title" class="text-[15px] font-semibold">{joined.name} joined {space}</h2>
75
+ <p class="mt-2 text-vc-muted">It sees new comments and replies under its own name. Remove it from your account menu at any time.</p>
76
+ <form method="dialog" class="mt-5 flex justify-end">
77
+ <button class="vc-dialog-button vc-dialog-primary">Done</button>
78
+ </form>
79
+ {:else if request}
80
+ <h2 id="vc-connect-title" class="text-[15px] font-semibold">{request.name} wants to join {space} as an agent</h2>
81
+ <p class="mt-2 text-vc-muted">
82
+ It will see every comment here, and may reply and act on them under its own name. Allow it only if you started it.
83
+ </p>
84
+ <p class="mt-4 text-vc-muted">The agent shows this code:</p>
85
+ <p class="mt-1 rounded-[8px] bg-vc-surface py-2 text-center font-mono text-xl tracking-[0.2em]">{request.code}</p>
86
+ {#if failed}<p role="alert" class="mt-3 text-red-600">{failed}</p>{/if}
87
+ <div class="mt-5 flex justify-end gap-2">
88
+ <button type="button" class="vc-dialog-button" disabled={busy} onclick={() => answer(false)}>Deny</button>
89
+ <button type="button" class="vc-dialog-button vc-dialog-primary" disabled={busy} onclick={() => answer(true)}>Allow</button>
90
+ </div>
91
+ {:else if failed}
92
+ <h2 id="vc-connect-title" class="text-[15px] font-semibold">This request cannot be answered</h2>
93
+ <p class="mt-2 text-vc-muted">{failed}</p>
94
+ <form method="dialog" class="mt-5 flex justify-end">
95
+ <button class="vc-dialog-button">Close</button>
96
+ </form>
97
+ {:else}
98
+ <h2 id="vc-connect-title" class="text-[15px] font-semibold">An agent wants to join</h2>
99
+ <p class="mt-2 text-vc-muted">Loading…</p>
100
+ {/if}
101
+ </dialog>
102
+
103
+ <style>
104
+ .vc-dialog-button {
105
+ height: 2rem;
106
+ border-radius: 9999px;
107
+ padding: 0 0.875rem;
108
+ font-weight: 500;
109
+ background: var(--color-vc-wash);
110
+ outline: none;
111
+ }
112
+ .vc-dialog-button:hover:not(:disabled) {
113
+ filter: brightness(0.96);
114
+ }
115
+ .vc-dialog-button:focus-visible {
116
+ box-shadow: 0 0 0 2px var(--color-vc-comment);
117
+ }
118
+ .vc-dialog-button:disabled {
119
+ opacity: 0.6;
120
+ }
121
+ .vc-dialog-primary {
122
+ background: var(--color-vc-comment);
123
+ color: white;
124
+ }
125
+ .vc-dialog-primary:focus-visible {
126
+ box-shadow: 0 0 0 2px white, 0 0 0 4px var(--color-vc-comment);
127
+ }
128
+ </style>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ id: string;
3
+ };
4
+ declare const Connect: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type Connect = ReturnType<typeof Connect>;
6
+ export default Connect;
@@ -8,7 +8,8 @@
8
8
  children,
9
9
  align = 'start',
10
10
  tone = 'light',
11
- class: className = ''
11
+ class: className = '',
12
+ onopen
12
13
  }: {
13
14
  /** Accessible name for the trigger. */
14
15
  label: string;
@@ -17,6 +18,8 @@
17
18
  align?: 'start' | 'end';
18
19
  tone?: 'light' | 'dark';
19
20
  class?: string;
21
+ /** Called as it opens, e.g. to load what it lists. */
22
+ onopen?: () => void;
20
23
  } = $props();
21
24
 
22
25
  const id = $props.id();
@@ -27,6 +30,7 @@
27
30
  function place(event: ToggleEvent) {
28
31
  open = event.newState === 'open';
29
32
  if (event.newState !== 'open' || !button || !popover) return;
33
+ onopen?.();
30
34
  const rect = button.getBoundingClientRect();
31
35
  const gutter = 12;
32
36
  const left = align === 'start' ? rect.left : rect.right - popover.offsetWidth;
@@ -7,6 +7,8 @@ type $$ComponentProps = {
7
7
  align?: 'start' | 'end';
8
8
  tone?: 'light' | 'dark';
9
9
  class?: string;
10
+ /** Called as it opens, e.g. to load what it lists. */
11
+ onopen?: () => void;
10
12
  };
11
13
  declare const Menu: import("svelte").Component<$$ComponentProps, {}, "">;
12
14
  type Menu = ReturnType<typeof Menu>;
@@ -18,6 +18,7 @@
18
18
  import { describeTarget, MAGNET, widen, type Box } from '../core/target.js';
19
19
  import { docBoxToClient, docBoxToSurface, intersect, spotOf, toClient, type Rect, type Surface } from '../core/surfaces.js';
20
20
  import { neck } from '../core/metaball.js';
21
+ import { activity } from '../core/presence.js';
21
22
  import type { CommentImage, CommentThread } from '../shared/types.js';
22
23
 
23
24
  const vc = useComments();
@@ -45,7 +46,7 @@
45
46
 
46
47
  /** Where cards on a surface can go: its stage, clear of the list. */
47
48
  function stageOf(surface: Surface): Rect {
48
- const panel = vc.active ? vc.panelRect ?? floatingPanel() : undefined;
49
+ const panel = vc.active && !vc.panelCollapsed ? vc.panelRect ?? floatingPanel() : undefined;
49
50
  const stage = intersect(surface.stage, { left: 0, top: 0, right: innerWidth, bottom: innerHeight });
50
51
  return panel && panel.left < stage.right && panel.left > stage.left ? { ...stage, right: panel.left } : stage;
51
52
  }
@@ -83,6 +84,18 @@
83
84
  return where && !where.hidden ? [{ thread, left: where.x, top: where.y }] : [];
84
85
  }));
85
86
 
87
+ /** Per thread, what those working on it say they are doing. */
88
+ const working = $derived.by(() => {
89
+ const lines = new Map<number, string>();
90
+ for (const participant of vc.participants) {
91
+ if (participant.state !== 'working' || participant.thread === undefined) continue;
92
+ const line = activity(participant)!;
93
+ const before = lines.get(participant.thread);
94
+ lines.set(participant.thread, before ? `${before} · ${line}` : line);
95
+ }
96
+ return lines;
97
+ });
98
+
86
99
  /** Beside the pin, on the side with room, kept inside the stage. */
87
100
  function beside(at: { left: number; top: number }, width: number, height: number, bounds: Rect) {
88
101
  let left = at.left + PIN + GAP;
@@ -310,6 +323,7 @@
310
323
  left={pin.left}
311
324
  top={pin.top}
312
325
  now={comments.now}
326
+ working={working.get(pin.thread.id)}
313
327
  open={pin.thread.id === vc.openId}
314
328
  onopen={() => {
315
329
  vc.draft = undefined;
@@ -61,146 +61,160 @@
61
61
  >
62
62
  <header class="flex h-11 shrink-0 items-center gap-0.5 pr-1.5 pl-3.5">
63
63
  <h2 class="min-w-0 flex-1 truncate font-semibold" title={comments.spaceName || undefined}>Comments</h2>
64
- <Menu label="Filter and sort" align="end" class="vc-tool-light size-7 rounded-full">
65
- {#snippet trigger()}<Icon name="filter" />{/snippet}
66
- {#snippet children(close)}
67
- <MenuItem checked={filters.sort === 'date'} onselect={() => { close(); filters.sort = 'date'; }}>Sort by date</MenuItem>
68
- <MenuItem checked={filters.sort === 'place'} onselect={() => { close(); filters.sort = 'place'; }}>Sort by place</MenuItem>
69
- <div class="my-1 border-t border-vc-line"></div>
70
- {#if elsewhere || filters.allPages}
71
- <MenuItem checked={filters.allPages} onselect={() => { close(); filters.allPages = !filters.allPages; }}>
72
- Show every page
73
- </MenuItem>
74
- {/if}
75
- <MenuItem checked={filters.showResolved} onselect={() => { close(); filters.showResolved = !filters.showResolved; }}>
76
- Show resolved comments
77
- </MenuItem>
78
- {#if viewer}
79
- <MenuItem checked={filters.onlyMine} onselect={() => { close(); filters.onlyMine = !filters.onlyMine; }}>
80
- Only your threads
81
- </MenuItem>
64
+ {#if !vc.panelCollapsed}
65
+ <Menu label="Filter and sort" align="end" class="vc-tool-light size-7 rounded-full">
66
+ {#snippet trigger()}<Icon name="filter" />{/snippet}
67
+ {#snippet children(close)}
68
+ <MenuItem checked={filters.sort === 'date'} onselect={() => { close(); filters.sort = 'date'; }}>Sort by date</MenuItem>
69
+ <MenuItem checked={filters.sort === 'place'} onselect={() => { close(); filters.sort = 'place'; }}>Sort by place</MenuItem>
82
70
  <div class="my-1 border-t border-vc-line"></div>
83
- <MenuItem checked={false} onselect={() => { close(); void comments.markAllRead(); }}>Mark all as read</MenuItem>
84
- {/if}
85
- {/snippet}
86
- </Menu>
71
+ {#if elsewhere || filters.allPages}
72
+ <MenuItem checked={filters.allPages} onselect={() => { close(); filters.allPages = !filters.allPages; }}>
73
+ Show every page
74
+ </MenuItem>
75
+ {/if}
76
+ <MenuItem checked={filters.showResolved} onselect={() => { close(); filters.showResolved = !filters.showResolved; }}>
77
+ Show resolved comments
78
+ </MenuItem>
79
+ {#if viewer}
80
+ <MenuItem checked={filters.onlyMine} onselect={() => { close(); filters.onlyMine = !filters.onlyMine; }}>
81
+ Only your threads
82
+ </MenuItem>
83
+ <div class="my-1 border-t border-vc-line"></div>
84
+ <MenuItem checked={false} onselect={() => { close(); void comments.markAllRead(); }}>Mark all as read</MenuItem>
85
+ {/if}
86
+ {/snippet}
87
+ </Menu>
88
+ {/if}
89
+ <button
90
+ type="button"
91
+ class="vc-tool-light size-7 rounded-full"
92
+ title={vc.panelCollapsed ? 'Expand comments' : 'Collapse comments'}
93
+ aria-label={vc.panelCollapsed ? 'Expand comments' : 'Collapse comments'}
94
+ aria-expanded={!vc.panelCollapsed}
95
+ onclick={() => (vc.panelCollapsed = !vc.panelCollapsed)}
96
+ >
97
+ <Icon name={vc.panelCollapsed ? 'chevron-left' : 'chevron-right'} />
98
+ </button>
87
99
  <button type="button" class="vc-tool-light size-7 rounded-full" title="Close comments (C)" aria-label="Close comments" onclick={onclose}>
88
100
  <Icon name="close" />
89
101
  </button>
90
102
  </header>
91
103
 
92
- <div class="shrink-0 border-b border-vc-line px-2.5 pb-2.5">
93
- <label class="flex h-8 items-center gap-2 rounded-[6px] bg-vc-surface px-2.5 text-vc-muted focus-within:ring-1 focus-within:ring-vc-comment">
94
- <Icon name="search" size={15} />
95
- <input
96
- type="search"
97
- placeholder="Search"
98
- aria-label="Search comments"
99
- bind:value={filters.query}
100
- onkeydown={(event) => { if (event.key === 'Escape') { filters.query = ''; (event.target as HTMLElement).blur(); } }}
101
- class="min-w-0 flex-1 bg-transparent text-vc-ink outline-none placeholder:text-vc-muted/80"
102
- />
103
- </label>
104
- </div>
105
-
106
- {#if !viewer && !comments.loading}
107
- <div class="shrink-0 border-b border-vc-line px-3.5 py-3">
108
- <p>Sign in to see comments and add your own.</p>
109
- <button type="button" class="mt-2 h-7 rounded-full bg-vc-comment px-3 font-medium text-white hover:brightness-110"
110
- onclick={() => vc.signIn()}>Sign in</button>
104
+ {#if !vc.panelCollapsed}
105
+ <div class="shrink-0 border-b border-vc-line px-2.5 pb-2.5">
106
+ <label class="flex h-8 items-center gap-2 rounded-[6px] bg-vc-surface px-2.5 text-vc-muted focus-within:ring-1 focus-within:ring-vc-comment">
107
+ <Icon name="search" size={15} />
108
+ <input
109
+ type="search"
110
+ placeholder="Search"
111
+ aria-label="Search comments"
112
+ bind:value={filters.query}
113
+ onkeydown={(event) => { if (event.key === 'Escape') { filters.query = ''; (event.target as HTMLElement).blur(); } }}
114
+ class="min-w-0 flex-1 bg-transparent text-vc-ink outline-none placeholder:text-vc-muted/80"
115
+ />
116
+ </label>
111
117
  </div>
112
- {/if}
113
118
 
114
- <ol class="min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain p-2">
115
- {#each visible as thread (thread.id)}
116
- {@const root = thread.comments[0]}
117
- {@const replies = thread.comments.length - 1}
118
- {@const selected = thread.id === vc.openId}
119
- <li class={['group relative rounded-[8px] px-3 py-2.5', selected ? 'bg-vc-comment-soft' : 'hover:bg-vc-surface']}>
120
- <button
121
- type="button"
122
- class="absolute inset-0 rounded-[8px] focus-visible:outline-2 focus-visible:outline-vc-comment"
123
- aria-label="Show comment #{thread.id} on {vc.titleOf(thread)}"
124
- aria-current={selected || undefined}
125
- onclick={() => onselect(thread)}
126
- ></button>
127
- <div class={['pointer-events-none relative', thread.resolvedAt && 'opacity-55']}>
128
- <div class="flex h-7 items-center">
129
- {#if thread.unread}
130
- <span class="absolute top-3 -left-2 size-1.5 rounded-full bg-vc-comment" title="Unread"></span>
119
+ {#if !viewer && !comments.loading}
120
+ <div class="shrink-0 border-b border-vc-line px-3.5 py-3">
121
+ <p>Sign in to see comments and add your own.</p>
122
+ <button type="button" class="mt-2 h-7 rounded-full bg-vc-comment px-3 font-medium text-white hover:brightness-110"
123
+ onclick={() => vc.signIn()}>Sign in</button>
124
+ </div>
125
+ {/if}
126
+
127
+ <ol class="min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain p-2">
128
+ {#each visible as thread (thread.id)}
129
+ {@const root = thread.comments[0]}
130
+ {@const replies = thread.comments.length - 1}
131
+ {@const selected = thread.id === vc.openId}
132
+ <li class={['group relative rounded-[8px] px-3 py-2.5', selected ? 'bg-vc-comment-soft' : 'hover:bg-vc-surface']}>
133
+ <button
134
+ type="button"
135
+ class="absolute inset-0 rounded-[8px] focus-visible:outline-2 focus-visible:outline-vc-comment"
136
+ aria-label="Show comment #{thread.id} on {vc.titleOf(thread)}"
137
+ aria-current={selected || undefined}
138
+ onclick={() => onselect(thread)}
139
+ ></button>
140
+ <div class={['pointer-events-none relative', thread.resolvedAt && 'opacity-55']}>
141
+ <div class="flex h-7 items-center">
142
+ {#if thread.unread}
143
+ <span class="absolute top-3 -left-2 size-1.5 rounded-full bg-vc-comment" title="Unread"></span>
144
+ {/if}
145
+ <Avatar user={root.author} size={28} />
146
+ </div>
147
+ <p class="mt-1.5 truncate text-vc-muted">#{thread.id} · {vc.titleOf(thread)}</p>
148
+ <p class="truncate">
149
+ <span class="font-medium">{root.author.name}</span>
150
+ <time class="ml-1 text-vc-muted" title={fullDate(root.createdAt)}>{ago(root.createdAt, comments.now)}</time>
151
+ </p>
152
+ <p class={['line-clamp-3 break-words whitespace-pre-line', thread.unread && 'font-medium']}>{commentText(root)}</p>
153
+ {#if replies > 0}
154
+ <p class="mt-1 text-xs text-vc-muted">
155
+ {replies} {replies === 1 ? 'reply' : 'replies'}
156
+ </p>
131
157
  {/if}
132
- <Avatar user={root.author} size={28} />
133
158
  </div>
134
- <p class="mt-1.5 truncate text-vc-muted">#{thread.id} · {vc.titleOf(thread)}</p>
135
- <p class="truncate">
136
- <span class="font-medium">{root.author.name}</span>
137
- <time class="ml-1 text-vc-muted" title={fullDate(root.createdAt)}>{ago(root.createdAt, comments.now)}</time>
138
- </p>
139
- <p class={['line-clamp-3 break-words whitespace-pre-line', thread.unread && 'font-medium']}>{commentText(root)}</p>
140
- {#if replies > 0}
141
- <p class="mt-1 text-xs text-vc-muted">
142
- {replies} {replies === 1 ? 'reply' : 'replies'}
143
- </p>
144
- {/if}
145
- </div>
146
- {#if viewer}
147
- <div class="absolute top-2.5 right-2 flex items-center">
148
- <div class={['transition-opacity', !selected && 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 has-[[aria-expanded=true]]:opacity-100']}>
149
- <Menu label="Thread options" align="end" class="vc-tool-light size-7 rounded-full">
150
- {#snippet trigger()}<Icon name="more" />{/snippet}
151
- {#snippet children(close)}
152
- <MenuItem onselect={() => { close(); void comments.markRead(thread.id, !thread.unread); }}>
153
- {thread.unread ? 'Mark as read' : 'Mark as unread'}
154
- </MenuItem>
155
- <MenuItem onselect={() => { close(); copyLink(thread); }}>Copy link</MenuItem>
156
- {#if root.author.sub === viewer.sub}
157
- <MenuItem onselect={() => {
158
- close();
159
- if (confirm('Delete this thread and all its replies?')) void comments.removeThread(thread.id);
160
- }}>Delete thread…</MenuItem>
161
- {/if}
162
- {/snippet}
163
- </Menu>
159
+ {#if viewer}
160
+ <div class="absolute top-2.5 right-2 flex items-center">
161
+ <div class={['transition-opacity', !selected && 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 has-[[aria-expanded=true]]:opacity-100']}>
162
+ <Menu label="Thread options" align="end" class="vc-tool-light size-7 rounded-full">
163
+ {#snippet trigger()}<Icon name="more" />{/snippet}
164
+ {#snippet children(close)}
165
+ <MenuItem onselect={() => { close(); void comments.markRead(thread.id, !thread.unread); }}>
166
+ {thread.unread ? 'Mark as read' : 'Mark as unread'}
167
+ </MenuItem>
168
+ <MenuItem onselect={() => { close(); copyLink(thread); }}>Copy link</MenuItem>
169
+ {#if root.author.sub === viewer.sub}
170
+ <MenuItem onselect={() => {
171
+ close();
172
+ if (confirm('Delete this thread and all its replies?')) void comments.removeThread(thread.id);
173
+ }}>Delete thread…</MenuItem>
174
+ {/if}
175
+ {/snippet}
176
+ </Menu>
177
+ </div>
178
+ <button
179
+ type="button"
180
+ class={['vc-tool-light size-7 rounded-full', thread.resolvedAt && 'text-vc-comment']}
181
+ aria-pressed={Boolean(thread.resolvedAt)}
182
+ title={thread.resolvedAt ? 'Reopen' : 'Resolve'}
183
+ aria-label={thread.resolvedAt ? `Reopen #${thread.id}` : `Resolve #${thread.id}`}
184
+ onclick={() => comments.resolve(thread.id, !thread.resolvedAt)}
185
+ >
186
+ <Icon name="resolve" size={18} />
187
+ </button>
164
188
  </div>
165
- <button
166
- type="button"
167
- class={['vc-tool-light size-7 rounded-full', thread.resolvedAt && 'text-vc-comment']}
168
- aria-pressed={Boolean(thread.resolvedAt)}
169
- title={thread.resolvedAt ? 'Reopen' : 'Resolve'}
170
- aria-label={thread.resolvedAt ? `Reopen #${thread.id}` : `Resolve #${thread.id}`}
171
- onclick={() => comments.resolve(thread.id, !thread.resolvedAt)}
172
- >
173
- <Icon name="resolve" size={18} />
174
- </button>
175
- </div>
176
- {/if}
177
- </li>
178
- {:else}
179
- {#if viewer && !comments.loading}
180
- <li class="px-3 py-6 text-center text-vc-muted">
181
- {#if filters.query.trim()}
182
- No comments match “{filters.query.trim()}”.
183
- {:else if filters.onlyMine}
184
- You have not started or replied to any threads.
185
- {:else if resolvedCount && !filters.showResolved}
186
- Every thread is resolved.
187
- <button type="button" class="text-vc-comment hover:underline" onclick={() => (filters.showResolved = true)}>
188
- Show resolved
189
- </button>
190
- {:else}
191
- No comments yet. Click {vc.surfaces.some((s) => s.element) ? 'a frame' : 'anywhere on the page'} to add one.
192
189
  {/if}
193
190
  </li>
194
- {/if}
195
- {/each}
196
- </ol>
191
+ {:else}
192
+ {#if viewer && !comments.loading}
193
+ <li class="px-3 py-6 text-center text-vc-muted">
194
+ {#if filters.query.trim()}
195
+ No comments match “{filters.query.trim()}”.
196
+ {:else if filters.onlyMine}
197
+ You have not started or replied to any threads.
198
+ {:else if resolvedCount && !filters.showResolved}
199
+ Every thread is resolved.
200
+ <button type="button" class="text-vc-comment hover:underline" onclick={() => (filters.showResolved = true)}>
201
+ Show resolved
202
+ </button>
203
+ {:else}
204
+ No comments yet. Click {vc.surfaces.some((s) => s.element) ? 'a frame' : 'anywhere on the page'} to add one.
205
+ {/if}
206
+ </li>
207
+ {/if}
208
+ {/each}
209
+ </ol>
197
210
 
198
- {#if comments.error}
199
- <div role="alert" class="m-2 mt-0 flex shrink-0 items-start gap-2 rounded-[8px] bg-vc-ink px-3 py-2 text-white">
200
- <p class="flex-1">{comments.error}</p>
201
- <button type="button" class="text-white/70 hover:text-white" aria-label="Dismiss" onclick={() => (comments.error = null)}>
202
- <Icon name="close" size={14} />
203
- </button>
204
- </div>
211
+ {#if comments.error}
212
+ <div role="alert" class="m-2 mt-0 flex shrink-0 items-start gap-2 rounded-[8px] bg-vc-ink px-3 py-2 text-white">
213
+ <p class="flex-1">{comments.error}</p>
214
+ <button type="button" class="text-white/70 hover:text-white" aria-label="Dismiss" onclick={() => (comments.error = null)}>
215
+ <Icon name="close" size={14} />
216
+ </button>
217
+ </div>
218
+ {/if}
205
219
  {/if}
206
220
  </aside>
@@ -27,13 +27,15 @@
27
27
  import { ago } from '../core/time.js';
28
28
  import type { CommentThread } from '../shared/types.js';
29
29
 
30
- let { thread, left, top, open, now, onopen }: {
30
+ let { thread, left, top, open, now, working, onopen }: {
31
31
  thread: CommentThread;
32
32
  /** The pin's point, in client pixels. */
33
33
  left: number;
34
34
  top: number;
35
35
  open: boolean;
36
36
  now: number;
37
+ /** Set while someone works on the thread: what they say they are doing. The pin pulses meanwhile. */
38
+ working?: string;
37
39
  onopen: () => void;
38
40
  } = $props();
39
41
 
@@ -49,6 +51,14 @@
49
51
  const expanded = $derived((hovered || focused) && !open && textWidth > 0);
50
52
  </script>
51
53
 
54
+ {#if working}
55
+ <span
56
+ class={['vc-pin-pulse vc-working pointer-events-none absolute', expanded ? 'z-30' : open ? 'z-20' : 'z-10']}
57
+ style:left="{left}px"
58
+ style:top="{top}px"
59
+ aria-hidden="true"
60
+ ></span>
61
+ {/if}
52
62
  <button
53
63
  type="button"
54
64
  data-vc-ui
@@ -68,7 +78,7 @@
68
78
  : expanded
69
79
  ? '0 4px 18px rgb(0 0 0 / 0.22)'
70
80
  : '0 1px 4px rgb(0 0 0 / 0.3)'}
71
- aria-label="Comment #{thread.id} by {root.author.name}: {text}"
81
+ aria-label="Comment #{thread.id} by {root.author.name}: {text}{working ? `. ${working}` : ''}"
72
82
  aria-expanded={open}
73
83
  in:pop={{ duration: 240, easing: backOut }}
74
84
  out:pop={{ duration: 140, easing: cubicIn }}
@@ -98,6 +108,9 @@
98
108
  {#if replies > 0}
99
109
  <span class="mt-0.5 block text-xs text-vc-muted">{replies} {replies === 1 ? 'reply' : 'replies'}</span>
100
110
  {/if}
111
+ {#if working}
112
+ <span class="mt-0.5 block truncate text-xs text-vc-working">{working}</span>
113
+ {/if}
101
114
  </span>
102
115
  {#if thread.unread}
103
116
  <span class="absolute top-0.5 right-0.5 size-2.5 rounded-full bg-vc-comment ring-2 ring-white"></span>
@@ -123,6 +136,13 @@
123
136
  top: 10px;
124
137
  left: 10px;
125
138
  }
139
+ /* The collapsed pin's shape, under it: its ring pulses out from the pin's edge. */
140
+ .vc-pin-pulse {
141
+ width: 32px;
142
+ height: 32px;
143
+ border-radius: 16px 16px 16px 0;
144
+ transform: translateY(-100%);
145
+ }
126
146
  .vc-pin-text {
127
147
  transition: opacity 140ms ease 60ms;
128
148
  }
@@ -6,6 +6,8 @@ type $$ComponentProps = {
6
6
  top: number;
7
7
  open: boolean;
8
8
  now: number;
9
+ /** Set while someone works on the thread: what they say they are doing. The pin pulses meanwhile. */
10
+ working?: string;
9
11
  onopen: () => void;
10
12
  };
11
13
  declare const Pin: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -0,0 +1,43 @@
1
+ <!--
2
+ Who else is here: an avatar for each other person and agent present, the
3
+ busiest first within each, beside the account button. Hover for what they do.
4
+ -->
5
+ <script lang="ts">
6
+ import type { VisualComments } from '../visual-comments.svelte.js';
7
+ import { activity, present, type Present } from '../core/presence.js';
8
+ import Avatar from './Avatar.svelte';
9
+ import { authorLabel } from './text.js';
10
+
11
+ let { vc, size = 24 }: { vc: VisualComments; size?: number } = $props();
12
+
13
+ const SHOWN = 4;
14
+ const others = $derived(present(vc.participants.filter((p) => p.author.sub !== vc.viewer?.sub)));
15
+ const shown = $derived(others.length > SHOWN ? others.slice(0, SHOWN - 1) : others);
16
+ const rest = $derived(others.slice(shown.length));
17
+
18
+ function describe(someone: Present) {
19
+ const doing = someone.participants.map(activity).filter(Boolean);
20
+ return [authorLabel(someone.author), ...doing].join('\n');
21
+ }
22
+ </script>
23
+
24
+ {#if others.length}
25
+ <ul class="flex items-center gap-1 px-1" aria-label="Here now">
26
+ {#each shown as someone (someone.author.sub)}
27
+ <li class="flex" aria-label={describe(someone)}>
28
+ <Avatar user={someone.author} {size} title={describe(someone)} class={someone.state === 'working' ? 'vc-working' : ''} />
29
+ </li>
30
+ {/each}
31
+ {#if rest.length}
32
+ <li
33
+ class="flex items-center justify-center rounded-full bg-current/15 text-[11px] font-medium"
34
+ style:width="{size}px"
35
+ style:height="{size}px"
36
+ title={rest.map(describe).join('\n\n')}
37
+ aria-label={rest.map(describe).join('\n\n')}
38
+ >
39
+ +{rest.length}
40
+ </li>
41
+ {/if}
42
+ </ul>
43
+ {/if}
@@ -0,0 +1,8 @@
1
+ import type { VisualComments } from '../visual-comments.svelte.js';
2
+ type $$ComponentProps = {
3
+ vc: VisualComments;
4
+ size?: number;
5
+ };
6
+ declare const Presence: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type Presence = ReturnType<typeof Presence>;
8
+ export default Presence;