visual-comments 0.0.0-stage → 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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -2
  3. package/dist/cli/index.d.ts +2 -0
  4. package/dist/cli/index.js +477 -0
  5. package/dist/core/aim.d.ts +21 -0
  6. package/dist/core/aim.js +21 -0
  7. package/dist/core/api.d.ts +5 -0
  8. package/dist/core/api.js +11 -0
  9. package/dist/core/metaball.d.ts +15 -0
  10. package/dist/core/metaball.js +42 -0
  11. package/dist/core/presence.d.ts +13 -0
  12. package/dist/core/presence.js +25 -0
  13. package/dist/core/surfaces.d.ts +95 -0
  14. package/dist/core/surfaces.js +197 -0
  15. package/dist/core/target.d.ts +59 -0
  16. package/dist/core/target.js +382 -0
  17. package/dist/core/threads.svelte.d.ts +80 -0
  18. package/dist/core/threads.svelte.js +336 -0
  19. package/dist/core/time.d.ts +3 -0
  20. package/dist/core/time.js +25 -0
  21. package/dist/index.d.ts +5 -0
  22. package/dist/index.js +6 -0
  23. package/dist/server/env.d.ts +78 -0
  24. package/dist/server/env.js +94 -0
  25. package/dist/server/index.d.ts +34 -0
  26. package/dist/server/index.js +74 -0
  27. package/dist/server/variables.d.ts +28 -0
  28. package/dist/server/variables.js +73 -0
  29. package/dist/shared/limits.d.ts +9 -0
  30. package/dist/shared/limits.js +10 -0
  31. package/dist/shared/protocol.d.ts +6 -0
  32. package/dist/shared/protocol.js +7 -0
  33. package/dist/shared/types.d.ts +157 -0
  34. package/dist/shared/types.js +2 -0
  35. package/dist/sveltekit/CommentTools.svelte +98 -0
  36. package/dist/sveltekit/CommentTools.svelte.d.ts +6 -0
  37. package/dist/sveltekit/Comments.svelte +104 -0
  38. package/dist/sveltekit/Comments.svelte.d.ts +25 -0
  39. package/dist/sveltekit/Toolbar.svelte +64 -0
  40. package/dist/sveltekit/Toolbar.svelte.d.ts +13 -0
  41. package/dist/sveltekit/ToolbarButton.svelte +66 -0
  42. package/dist/sveltekit/ToolbarButton.svelte.d.ts +12 -0
  43. package/dist/sveltekit/context.d.ts +16 -0
  44. package/dist/sveltekit/context.js +9 -0
  45. package/dist/sveltekit/index.d.ts +6 -0
  46. package/dist/sveltekit/index.js +6 -0
  47. package/dist/ui/Account.svelte +106 -0
  48. package/dist/ui/Account.svelte.d.ts +8 -0
  49. package/dist/ui/App.svelte +77 -0
  50. package/dist/ui/App.svelte.d.ts +7 -0
  51. package/dist/ui/Avatar.svelte +64 -0
  52. package/dist/ui/Avatar.svelte.d.ts +11 -0
  53. package/dist/ui/Composer.svelte +267 -0
  54. package/dist/ui/Composer.svelte.d.ts +20 -0
  55. package/dist/ui/Connect.svelte +128 -0
  56. package/dist/ui/Connect.svelte.d.ts +6 -0
  57. package/dist/ui/Icon.svelte +44 -0
  58. package/dist/ui/Icon.svelte.d.ts +29 -0
  59. package/dist/ui/Menu.svelte +69 -0
  60. package/dist/ui/Menu.svelte.d.ts +15 -0
  61. package/dist/ui/MenuItem.svelte +38 -0
  62. package/dist/ui/MenuItem.svelte.d.ts +12 -0
  63. package/dist/ui/Overlay.svelte +502 -0
  64. package/dist/ui/Overlay.svelte.d.ts +3 -0
  65. package/dist/ui/Panel.svelte +220 -0
  66. package/dist/ui/Panel.svelte.d.ts +10 -0
  67. package/dist/ui/Pin.svelte +156 -0
  68. package/dist/ui/Pin.svelte.d.ts +15 -0
  69. package/dist/ui/Presence.svelte +43 -0
  70. package/dist/ui/Presence.svelte.d.ts +8 -0
  71. package/dist/ui/ThreadCard.svelte +201 -0
  72. package/dist/ui/ThreadCard.svelte.d.ts +13 -0
  73. package/dist/ui/Toolbar.svelte +35 -0
  74. package/dist/ui/Toolbar.svelte.d.ts +18 -0
  75. package/dist/ui/context.d.ts +3 -0
  76. package/dist/ui/context.js +8 -0
  77. package/dist/ui/generated/styles.css +2 -0
  78. package/dist/ui/generated/styles.d.ts +2 -0
  79. package/dist/ui/generated/styles.js +3 -0
  80. package/dist/ui/styles.css +113 -0
  81. package/dist/ui/text.d.ts +5 -0
  82. package/dist/ui/text.js +11 -0
  83. package/dist/visual-comments.svelte.d.ts +230 -0
  84. package/dist/visual-comments.svelte.js +693 -0
  85. package/dist/vite/index.d.ts +20 -0
  86. package/dist/vite/index.js +78 -0
  87. package/dist-widget/visual-comments.js +58 -0
  88. package/package.json +86 -3
@@ -0,0 +1,28 @@
1
+ /** The variables `commentsFromEnv` reads. */
2
+ export type CommentsEnvName = 'VISUAL_COMMENTS_ENABLED' | 'VISUAL_COMMENTS_SERVER' | 'VISUAL_COMMENTS_SPACE' | 'VISUAL_COMMENTS_SECRET' | 'VISUAL_COMMENTS_PATH' | 'VISUAL_COMMENTS_AUTH' | 'VISUAL_COMMENTS_OIDC_ISSUER' | 'VISUAL_COMMENTS_OIDC_CLIENT_ID' | 'VISUAL_COMMENTS_OIDC_CLIENT_SECRET' | 'VISUAL_COMMENTS_SESSION_SECRET';
3
+ /**
4
+ * Where the variables come from: `process.env`, SvelteKit's `$app/env/private`
5
+ * (`import * as env from '$app/env/private'`) or `$env/dynamic/private` before
6
+ * SvelteKit 3. Values that are not strings count as unset.
7
+ */
8
+ export type CommentsEnv = {
9
+ readonly [K in CommentsEnvName]?: unknown;
10
+ };
11
+ export declare function invalid(name: CommentsEnvName, message: string): Error;
12
+ /** A variable's value, checked; undefined when unset or empty. */
13
+ export declare function read(env: CommentsEnv, name: CommentsEnvName): string | undefined;
14
+ /**
15
+ * The variables for SvelteKit's `src/env.ts`, all private and read when the
16
+ * app starts, so one build serves every deployment:
17
+ *
18
+ * ```ts
19
+ * import { defineEnvVars } from '@sveltejs/kit/env';
20
+ * import { commentsVariables } from 'visual-comments/server';
21
+ *
22
+ * export const variables = defineEnvVars({ ...commentsVariables });
23
+ * ```
24
+ */
25
+ export declare const commentsVariables: { readonly [K in CommentsEnvName]: {
26
+ description: string;
27
+ schema: (value: string | undefined) => string | undefined;
28
+ }; };
@@ -0,0 +1,73 @@
1
+ // The variables `commentsFromEnv` reads, with their checks. Kept apart from it, so a build
2
+ // without the comments (`visual-comments/vite`) still declares them.
3
+ const isPath = (value) => (value.startsWith('/') ? undefined : 'must be a path that starts with /, e.g. /_comments');
4
+ const isUrl = (value) => (URL.canParse(value) && /^https?:$/.test(new URL(value).protocol) ? undefined : 'must be an http(s) URL');
5
+ const isSecret = (value) => (value.length >= 32 ? undefined : 'must be at least 32 characters');
6
+ const CHECKS = {
7
+ VISUAL_COMMENTS_ENABLED: {
8
+ description: 'Whether comments are on: `true`, `false` or `dev` (only while the dev server runs). Default `false`.',
9
+ check: (value) => (['true', 'false', 'dev'].includes(value) ? undefined : 'must be true, false or dev')
10
+ },
11
+ VISUAL_COMMENTS_SERVER: {
12
+ description: 'The comment server, e.g. `https://comments.convex.works`. Required while comments are on.',
13
+ check: isUrl
14
+ },
15
+ VISUAL_COMMENTS_SPACE: {
16
+ description: "The site's space on the comment server. Required while comments are on."
17
+ },
18
+ VISUAL_COMMENTS_SECRET: {
19
+ description: "The space's secret, at least 32 characters. Required while comments are on. Never sent to browsers.",
20
+ check: isSecret
21
+ },
22
+ VISUAL_COMMENTS_PATH: {
23
+ description: 'Where the site answers comment requests, base path included. Default `/_comments`.',
24
+ check: isPath
25
+ },
26
+ VISUAL_COMMENTS_AUTH: {
27
+ description: "Where the site's sign-in routes are, base path included. Default `/auth`.",
28
+ check: isPath
29
+ },
30
+ VISUAL_COMMENTS_OIDC_ISSUER: {
31
+ description: "With the comments' own sign-in: the OpenID Connect provider, e.g. `https://id.convex.works`.",
32
+ check: isUrl
33
+ },
34
+ VISUAL_COMMENTS_OIDC_CLIENT_ID: {
35
+ description: "With the comments' own sign-in: their confidential client at the provider."
36
+ },
37
+ VISUAL_COMMENTS_OIDC_CLIENT_SECRET: {
38
+ description: "With the comments' own sign-in: that client's secret."
39
+ },
40
+ VISUAL_COMMENTS_SESSION_SECRET: {
41
+ description: "With the comments' own sign-in: encrypts their session cookie. At least 32 characters. Optional under the dev server.",
42
+ check: isSecret
43
+ }
44
+ };
45
+ export function invalid(name, message) {
46
+ return new Error(`${name} ${message}.`);
47
+ }
48
+ /** A variable's value, checked; undefined when unset or empty. */
49
+ export function read(env, name) {
50
+ const value = env[name];
51
+ if (typeof value !== 'string' || value === '')
52
+ return undefined;
53
+ const problem = CHECKS[name].check?.(value);
54
+ if (problem)
55
+ throw invalid(name, problem);
56
+ return value;
57
+ }
58
+ /**
59
+ * The variables for SvelteKit's `src/env.ts`, all private and read when the
60
+ * app starts, so one build serves every deployment:
61
+ *
62
+ * ```ts
63
+ * import { defineEnvVars } from '@sveltejs/kit/env';
64
+ * import { commentsVariables } from 'visual-comments/server';
65
+ *
66
+ * export const variables = defineEnvVars({ ...commentsVariables });
67
+ * ```
68
+ */
69
+ export const commentsVariables = Object.fromEntries(Object.entries(CHECKS).map(([name, { description }]) => [name, {
70
+ description,
71
+ // Optional, and checked when the app starts.
72
+ schema: (value) => read({ [name]: value }, name)
73
+ }]));
@@ -0,0 +1,9 @@
1
+ /** Per image. A proxy in front of the server must allow request bodies this large. */
2
+ export declare const MAX_IMAGE_BYTES: number;
3
+ export declare const MAX_IMAGES = 6;
4
+ /** No SVG: it can carry script. */
5
+ export declare const IMAGE_TYPES: string[];
6
+ export declare const MAX_BODY = 5000;
7
+ /** Context entries a thread keeps, and the length of each value. */
8
+ export declare const MAX_CONTEXT = 20;
9
+ export declare const MAX_CONTEXT_VALUE = 200;
@@ -0,0 +1,10 @@
1
+ // Limits shared by the browser and the server.
2
+ /** Per image. A proxy in front of the server must allow request bodies this large. */
3
+ export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
4
+ export const MAX_IMAGES = 6;
5
+ /** No SVG: it can carry script. */
6
+ export const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
7
+ export const MAX_BODY = 5000;
8
+ /** Context entries a thread keeps, and the length of each value. */
9
+ export const MAX_CONTEXT = 20;
10
+ export const MAX_CONTEXT_VALUE = 200;
@@ -0,0 +1,6 @@
1
+ /** The header with who is signed in on the site: their `Viewer` as base64url-encoded JSON. */
2
+ export declare const USER_HEADER = "visual-comments-user";
3
+ /** The header with an agent's token, passed on by the site's server instead of `USER_HEADER`. */
4
+ export declare const AGENT_HEADER = "visual-comments-agent";
5
+ /** Agent tokens start with this, so the site's server can tell them from other bearer tokens. */
6
+ export declare const AGENT_TOKEN_PREFIX = "vca_";
@@ -0,0 +1,7 @@
1
+ // How a site's server talks to the comment server.
2
+ /** The header with who is signed in on the site: their `Viewer` as base64url-encoded JSON. */
3
+ export const USER_HEADER = 'visual-comments-user';
4
+ /** The header with an agent's token, passed on by the site's server instead of `USER_HEADER`. */
5
+ export const AGENT_HEADER = 'visual-comments-agent';
6
+ /** Agent tokens start with this, so the site's server can tell them from other bearer tokens. */
7
+ export const AGENT_TOKEN_PREFIX = 'vca_';
@@ -0,0 +1,157 @@
1
+ /** Who wrote a comment. From the site's sign-in, saved by the server. */
2
+ export interface Author {
3
+ sub: string;
4
+ name: string;
5
+ picture?: string;
6
+ /** Set when the author is an agent: it joined the space when a person approved it. */
7
+ agent?: {
8
+ addedBy: {
9
+ sub: string;
10
+ name: string;
11
+ };
12
+ };
13
+ }
14
+ /** The person signed in on the site. */
15
+ export interface Viewer extends Author {
16
+ email?: string;
17
+ }
18
+ /** An image in a comment, served at `<endpoint>/images/<id>/<key>`. */
19
+ export interface CommentImage {
20
+ id: number;
21
+ /** Part of its address, so addresses cannot be guessed from ids. */
22
+ key: string;
23
+ /** Its size in pixels, so it can be laid out before it loads. */
24
+ width: number;
25
+ height: number;
26
+ }
27
+ export interface Comment {
28
+ id: number;
29
+ author: Author;
30
+ /** Empty when the comment is only images. */
31
+ body: string;
32
+ images: CommentImage[];
33
+ /** Milliseconds since the epoch. */
34
+ createdAt: number;
35
+ editedAt: number | null;
36
+ /** Sent but not yet confirmed by the server. */
37
+ pending?: boolean;
38
+ }
39
+ /** The element a pin was placed on, as it was then. */
40
+ export interface CommentAnchor {
41
+ /** A CSS selector for it in its document. */
42
+ selector: string;
43
+ /** What it is and what it says, e.g. `button “Add prompts”`. */
44
+ label: string;
45
+ /** Its visible text, shortened. */
46
+ text: string;
47
+ /** Its box in the surface's CSS pixels. */
48
+ x: number;
49
+ y: number;
50
+ width: number;
51
+ height: number;
52
+ }
53
+ /** A pin and the conversation under it. The first comment opens the thread. */
54
+ export interface CommentThread {
55
+ /** Its number in the space, shown as #id. Never reused. */
56
+ id: number;
57
+ /** The page it is on: by default the URL's origin and path. */
58
+ page: string;
59
+ /** The address it was placed at. */
60
+ url: string;
61
+ /** The surface on the page, or '' for the page itself. */
62
+ surface: string;
63
+ /** The surface's title, or the page's, when the thread was placed. */
64
+ title: string;
65
+ /** Pin position in the surface's CSS pixels, from its top-left corner. */
66
+ x: number;
67
+ y: number;
68
+ /** Null for a pin in open space. */
69
+ anchor: CommentAnchor | null;
70
+ /** What the host said about where the thread was placed, e.g. the viewport size. */
71
+ context: Record<string, string>;
72
+ createdAt: number;
73
+ resolvedAt: number | null;
74
+ resolvedBy: Author | null;
75
+ /** Someone else has written in the thread since the viewer last opened it. */
76
+ unread: boolean;
77
+ comments: Comment[];
78
+ }
79
+ export interface CommentsSnapshot {
80
+ space: {
81
+ id: string;
82
+ name: string;
83
+ };
84
+ /** Null when signed out. */
85
+ viewer: Viewer | null;
86
+ threads: CommentThread[];
87
+ }
88
+ /** What a client sends to start a thread. */
89
+ export interface NewThreadInput {
90
+ page: string;
91
+ url: string;
92
+ surface: string;
93
+ title: string;
94
+ x: number;
95
+ y: number;
96
+ anchor?: CommentAnchor | null;
97
+ context?: Record<string, string>;
98
+ body: string;
99
+ images?: number[];
100
+ }
101
+ /** What someone present is doing. */
102
+ export type PresenceState = 'viewing' | 'typing' | 'working';
103
+ /** Someone in the space right now: one per browser tab, or one per task of an agent. */
104
+ export interface Participant {
105
+ /** Stable while they stay. */
106
+ id: string;
107
+ author: Author;
108
+ state: PresenceState;
109
+ page?: string;
110
+ surface?: string;
111
+ thread?: number;
112
+ /** What they are doing, in their words, e.g. `Tightening the table`. */
113
+ note?: string;
114
+ /** When the state last changed, in milliseconds since the epoch. */
115
+ since: number;
116
+ }
117
+ /** What a client sends to `POST /presence`. */
118
+ export interface PresenceUpdate {
119
+ /** A browser's connection, from the event stream's `hello` event. Agents leave it out. */
120
+ connection?: string;
121
+ /** Agents only: one participant per key, so one agent can work on several things at once. Default `''`. */
122
+ key?: string;
123
+ /** `gone` removes the participant. */
124
+ state: PresenceState | 'gone';
125
+ page?: string;
126
+ surface?: string;
127
+ thread?: number;
128
+ note?: string;
129
+ }
130
+ /** An agent of the space. */
131
+ export interface Agent {
132
+ id: string;
133
+ name: string;
134
+ addedBy: {
135
+ sub: string;
136
+ name: string;
137
+ };
138
+ createdAt: number;
139
+ lastSeenAt: number | null;
140
+ }
141
+ /** An agent asking to join the space, waiting for a person to approve it. */
142
+ export interface AgentRequest {
143
+ id: string;
144
+ name: string;
145
+ /** Shown to the agent and to the person approving, so both know it is the same request. */
146
+ code: string;
147
+ expiresAt: number;
148
+ }
149
+ /** The request as the agent that asked gets it: with the secret it collects its token with. */
150
+ export interface NewAgentRequest extends AgentRequest {
151
+ secret: string;
152
+ }
153
+ /** What `GET /agent/next` answers: the threads with something new for the agent. */
154
+ export interface AgentInbox {
155
+ threads: CommentThread[];
156
+ participants: Participant[];
157
+ }
@@ -0,0 +1,2 @@
1
+ // Shapes shared by the browser client and the server.
2
+ export {};
@@ -0,0 +1,98 @@
1
+ <!--
2
+ The comments' part of the toolbar, while they are on: the comment button and the account (who
3
+ is here, and the viewer's avatar or Sign in), after a divider when the app's tools come first.
4
+ Left out of builds without the comments (visual-comments/vite).
5
+ -->
6
+ <script lang="ts">
7
+ import { page } from '$app/state';
8
+ import { useComments } from './context.js';
9
+ import ToolbarButton from './ToolbarButton.svelte';
10
+
11
+ let { divider = false }: { divider?: boolean } = $props();
12
+
13
+ const comments = useComments();
14
+ const shortcut = $derived(comments?.current?.options.shortcut ?? 'c');
15
+ const viewer = $derived(comments?.viewer);
16
+ const initial = $derived(Array.from(viewer?.name.trim() || '?')[0].toLocaleUpperCase());
17
+ const signIn = $derived(`${comments?.auth.replace(/\/$/, '')}/login?${new URLSearchParams({ returnTo: `${page.url.pathname}${page.url.search}` })}`);
18
+ </script>
19
+
20
+ {#if comments?.enabled}
21
+ {#if divider}<span class="vc-toolbar-divider" aria-hidden="true"></span>{/if}
22
+ <!-- The comments find this button by `data-vc-toggle`: clicks turn comment mode on and off, and they set `aria-pressed` and `data-vc-unread`. -->
23
+ <ToolbarButton
24
+ data-vc-toggle
25
+ label={shortcut === false ? 'Comments' : `Comments (${shortcut.toUpperCase()})`}
26
+ disabled={!comments.current}
27
+ >
28
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
29
+ <path d="M12 3.5a8.5 8.5 0 1 1 0 17H3.5V12A8.5 8.5 0 0 1 12 3.5z" />
30
+ </svg>
31
+ </ToolbarButton>
32
+ <!-- The comments put the account button here once they have started; until then, it shows who the server said is signed in. -->
33
+ <span class="vc-toolbar-account" data-vc-account>
34
+ {#if !comments.current && viewer}
35
+ <span class="vc-toolbar-tool vc-toolbar-me" title={viewer.name}>
36
+ <span class="vc-toolbar-avatar" aria-hidden="true">
37
+ {#if viewer.picture}<img src={viewer.picture} alt="" referrerpolicy="no-referrer" />{:else}{initial}{/if}
38
+ </span>
39
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
40
+ <path d="m6 9 6 6 6-6" />
41
+ </svg>
42
+ </span>
43
+ {:else if !comments.current && viewer === null}
44
+ <a class="vc-toolbar-tool vc-toolbar-sign-in" href={signIn}>Sign in</a>
45
+ {/if}
46
+ </span>
47
+ {/if}
48
+
49
+ <style>
50
+ .vc-toolbar-divider {
51
+ width: 1px;
52
+ height: 1.25rem;
53
+ margin: 0 0.25rem;
54
+ background: currentColor;
55
+ opacity: 0.25;
56
+ }
57
+ /* The account draws in its own shadow root and takes the toolbar's text colour. */
58
+ .vc-toolbar-account {
59
+ display: inline-flex;
60
+ }
61
+ /* Until then, the same sizes as the account button. */
62
+ .vc-toolbar-tool {
63
+ display: inline-flex;
64
+ height: 2rem;
65
+ align-items: center;
66
+ gap: 0.375rem;
67
+ border-radius: 9999px;
68
+ color: inherit;
69
+ text-decoration: none;
70
+ }
71
+ .vc-toolbar-me {
72
+ padding: 0 0.5rem 0 0.125rem;
73
+ }
74
+ .vc-toolbar-sign-in {
75
+ padding: 0 0.875rem;
76
+ font-weight: 500;
77
+ }
78
+ .vc-toolbar-sign-in:hover {
79
+ background: color-mix(in srgb, currentColor 14%, transparent);
80
+ }
81
+ .vc-toolbar-avatar {
82
+ display: grid;
83
+ width: 28px;
84
+ height: 28px;
85
+ place-items: center;
86
+ overflow: hidden;
87
+ border-radius: 9999px;
88
+ background: var(--vc-avatar, #009aaa);
89
+ color: #fff;
90
+ font-size: 14px;
91
+ line-height: 1;
92
+ }
93
+ .vc-toolbar-avatar img {
94
+ width: 100%;
95
+ height: 100%;
96
+ object-fit: cover;
97
+ }
98
+ </style>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ divider?: boolean;
3
+ };
4
+ declare const CommentTools: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type CommentTools = ReturnType<typeof CommentTools>;
6
+ export default CommentTools;
@@ -0,0 +1,104 @@
1
+ <!--
2
+ Adds comments to a SvelteKit app. Put it in the root layout, around the
3
+ pages, with the config the server's `commentsFromEnv` gives the layout's
4
+ `load`. With no config, comments are off: it renders only the pages, and the
5
+ browser never loads the comments' code.
6
+ -->
7
+ <script lang="ts" module>
8
+ import type { Snippet } from 'svelte';
9
+ import type { CommentsClientConfig } from '../server/env.js';
10
+ import type { VisualComments, VisualCommentsOptions } from '../visual-comments.svelte.js';
11
+
12
+ export interface CommentsProps extends Omit<VisualCommentsOptions, 'endpoint' | 'auth' | 'page'> {
13
+ /** Where the comments and sign-in are, from `commentsFromEnv(...).client`. Null or undefined: comments are off. */
14
+ config: CommentsClientConfig | null | undefined;
15
+ /**
16
+ * Which threads a page shows. `path` (default): each address's own, by
17
+ * origin and path. `route`: every address of a route shares them, so
18
+ * `/projects/1` and `/projects/2` show the threads of `/projects/[id]`.
19
+ * A string: one set for every page. A function: a key for each address.
20
+ */
21
+ page?: 'path' | 'route' | (string & {}) | ((url: URL) => string);
22
+ /** Signs in and out another way than the sign-in routes of `config.auth`. */
23
+ auth?: { signIn(): void; signOut(): void };
24
+ /** The comments while they run, to bind to. */
25
+ comments?: VisualComments;
26
+ children?: Snippet;
27
+ }
28
+ </script>
29
+
30
+ <script lang="ts">
31
+ import { untrack } from 'svelte';
32
+ import { goto } from '$app/navigation';
33
+ import { page as current } from '$app/state';
34
+ import { setAppComments } from './context.js';
35
+
36
+ let { config, page = 'path', auth, comments = $bindable(), children, ...rest }: CommentsProps = $props();
37
+
38
+ // Started again only when where the comments are changes, not whenever `load` runs again.
39
+ const endpoint = $derived(config?.endpoint);
40
+ const authPath = $derived(config?.auth);
41
+
42
+ setAppComments({
43
+ get enabled() { return Boolean(endpoint); },
44
+ get current() { return comments; },
45
+ get viewer() { return config?.viewer; },
46
+ get auth() { return authPath ?? '/auth'; }
47
+ });
48
+
49
+ /** The page key of an address: its origin and path, without a trailing slash. */
50
+ function pathKey(url: URL) {
51
+ const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, '') : url.pathname;
52
+ return `${url.origin}${path}`;
53
+ }
54
+
55
+ function pageOption(option: NonNullable<CommentsProps['page']>): VisualCommentsOptions['page'] {
56
+ if (option === 'path') return undefined;
57
+ // Read when the comments measure the page, by when the router has updated `page`.
58
+ if (option === 'route') return (url) => (current.route.id ? `${url.origin}${current.route.id}` : pathKey(url));
59
+ return option;
60
+ }
61
+
62
+ /** Goes to a thread on another page of the app with the router, so the comments stay open. */
63
+ function navigate(link: string) {
64
+ const url = new URL(link, location.href);
65
+ if (url.origin !== location.origin) return location.assign(link);
66
+ // The thread is already open; its link's `#vc=<id>` is only for addresses opened anew.
67
+ const hash = url.hash.slice(1).split('&').filter((part) => part && !part.startsWith('vc=')).join('&');
68
+ void goto(`${url.pathname}${url.search}${hash ? `#${hash}` : ''}`).then(() => comments?.relayout());
69
+ }
70
+
71
+ const options = $derived<VisualCommentsOptions>({
72
+ navigate,
73
+ ...rest,
74
+ page: pageOption(page),
75
+ ...(auth ? { auth } : {})
76
+ });
77
+
78
+ $effect(() => {
79
+ if (!endpoint) return;
80
+ const start = { endpoint, auth: authPath ?? '/auth' };
81
+ let stopped = false;
82
+ let instance: VisualComments | undefined;
83
+ // Loaded only when comments are on, so an app without them never downloads their code.
84
+ void import('../index.js').then(({ createVisualComments }) => {
85
+ if (stopped) return;
86
+ // Who the page was loaded for, so the account shows without waiting for the server.
87
+ instance = createVisualComments({ ...untrack(() => options), ...start, viewer: untrack(() => config?.viewer) });
88
+ comments = instance;
89
+ });
90
+ return () => {
91
+ stopped = true;
92
+ instance?.destroy();
93
+ if (untrack(() => comments) === instance) comments = undefined;
94
+ };
95
+ });
96
+
97
+ // Options the app changes later, e.g. a `show` that depends on its state.
98
+ $effect(() => {
99
+ const next = options;
100
+ untrack(() => comments)?.configure(next);
101
+ });
102
+ </script>
103
+
104
+ {@render children?.()}
@@ -0,0 +1,25 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { CommentsClientConfig } from '../server/env.js';
3
+ import type { VisualComments, VisualCommentsOptions } from '../visual-comments.svelte.js';
4
+ export interface CommentsProps extends Omit<VisualCommentsOptions, 'endpoint' | 'auth' | 'page'> {
5
+ /** Where the comments and sign-in are, from `commentsFromEnv(...).client`. Null or undefined: comments are off. */
6
+ config: CommentsClientConfig | null | undefined;
7
+ /**
8
+ * Which threads a page shows. `path` (default): each address's own, by
9
+ * origin and path. `route`: every address of a route shares them, so
10
+ * `/projects/1` and `/projects/2` show the threads of `/projects/[id]`.
11
+ * A string: one set for every page. A function: a key for each address.
12
+ */
13
+ page?: 'path' | 'route' | (string & {}) | ((url: URL) => string);
14
+ /** Signs in and out another way than the sign-in routes of `config.auth`. */
15
+ auth?: {
16
+ signIn(): void;
17
+ signOut(): void;
18
+ };
19
+ /** The comments while they run, to bind to. */
20
+ comments?: VisualComments;
21
+ children?: Snippet;
22
+ }
23
+ declare const Comments: import("svelte").Component<CommentsProps, {}, "comments">;
24
+ type Comments = ReturnType<typeof Comments>;
25
+ export default Comments;
@@ -0,0 +1,64 @@
1
+ <!--
2
+ A toolbar in the style of Figma's and Vercel's: a pill of dark glass,
3
+ centred at the bottom of the window (or the top, or wherever the app puts it
4
+ with `position="inline"`). The app's own tools go first, as its content,
5
+ e.g. `ToolbarButton`s; then, while comments are on, the comment button and
6
+ the account (who is here, and the viewer's avatar or Sign in). It replaces
7
+ the comments' own floating toolbar. With comments off and no tools of its
8
+ own, it is not shown.
9
+ -->
10
+ <script lang="ts">
11
+ import type { Snippet } from 'svelte';
12
+ import CommentTools from './CommentTools.svelte';
13
+ import { useComments } from './context.js';
14
+
15
+ let { position = 'bottom', label = 'Toolbar', class: className, children }: {
16
+ /** `bottom` or `top` of the window, centred; `inline` where the app places it. Default `bottom`. */
17
+ position?: 'bottom' | 'top' | 'inline';
18
+ /** The toolbar's name for screen readers. */
19
+ label?: string;
20
+ class?: string;
21
+ /** The app's own tools. */
22
+ children?: Snippet;
23
+ } = $props();
24
+
25
+ const comments = useComments();
26
+ </script>
27
+
28
+ {#if comments?.enabled || children}
29
+ <!-- Never takes comments itself, even over a surface. -->
30
+ <div role="toolbar" aria-label={label} data-vc-ignore class={['vc-toolbar', position, className]}>
31
+ {@render children?.()}
32
+ <CommentTools divider={Boolean(children)} />
33
+ </div>
34
+ {/if}
35
+
36
+ <style>
37
+ .vc-toolbar {
38
+ display: inline-flex;
39
+ align-items: center;
40
+ gap: 0.125rem;
41
+ border-radius: 9999px;
42
+ padding: 0.25rem;
43
+ background: var(--vc-island, rgb(50 50 50 / 0.72));
44
+ color: var(--vc-island-ink, #fff);
45
+ font: 13px var(--vc-font, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif);
46
+ box-shadow: 0 2px 10px rgb(0 0 0 / 0.2);
47
+ backdrop-filter: blur(5px);
48
+ -webkit-backdrop-filter: blur(5px);
49
+ }
50
+ .vc-toolbar.bottom,
51
+ .vc-toolbar.top {
52
+ position: fixed;
53
+ left: 50%;
54
+ translate: -50% 0;
55
+ /* Under the comments' own layer, so their cards and menus open over it. */
56
+ z-index: 2147483000;
57
+ }
58
+ .vc-toolbar.bottom {
59
+ bottom: 1rem;
60
+ }
61
+ .vc-toolbar.top {
62
+ top: 1rem;
63
+ }
64
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ /** `bottom` or `top` of the window, centred; `inline` where the app places it. Default `bottom`. */
4
+ position?: 'bottom' | 'top' | 'inline';
5
+ /** The toolbar's name for screen readers. */
6
+ label?: string;
7
+ class?: string;
8
+ /** The app's own tools. */
9
+ children?: Snippet;
10
+ };
11
+ declare const Toolbar: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type Toolbar = ReturnType<typeof Toolbar>;
13
+ export default Toolbar;