@dfosco/storyboard-core 1.1.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 (42) hide show
  1. package/package.json +18 -0
  2. package/src/comments/api.js +196 -0
  3. package/src/comments/api.test.js +194 -0
  4. package/src/comments/auth.js +79 -0
  5. package/src/comments/auth.test.js +60 -0
  6. package/src/comments/commentMode.js +63 -0
  7. package/src/comments/commentMode.test.js +87 -0
  8. package/src/comments/config.js +43 -0
  9. package/src/comments/config.test.js +76 -0
  10. package/src/comments/graphql.js +65 -0
  11. package/src/comments/graphql.test.js +95 -0
  12. package/src/comments/index.js +40 -0
  13. package/src/comments/metadata.js +52 -0
  14. package/src/comments/metadata.test.js +110 -0
  15. package/src/comments/queries.js +182 -0
  16. package/src/comments/ui/CommentOverlay.js +52 -0
  17. package/src/comments/ui/authModal.js +349 -0
  18. package/src/comments/ui/commentWindow.js +872 -0
  19. package/src/comments/ui/commentsDrawer.js +389 -0
  20. package/src/comments/ui/composer.js +248 -0
  21. package/src/comments/ui/mount.js +364 -0
  22. package/src/devtools.js +365 -0
  23. package/src/devtools.test.js +81 -0
  24. package/src/dotPath.js +53 -0
  25. package/src/dotPath.test.js +114 -0
  26. package/src/hashSubscribe.js +19 -0
  27. package/src/hashSubscribe.test.js +62 -0
  28. package/src/hideMode.js +421 -0
  29. package/src/hideMode.test.js +224 -0
  30. package/src/index.js +38 -0
  31. package/src/interceptHideParams.js +35 -0
  32. package/src/interceptHideParams.test.js +90 -0
  33. package/src/loader.js +212 -0
  34. package/src/loader.test.js +232 -0
  35. package/src/localStorage.js +134 -0
  36. package/src/localStorage.test.js +148 -0
  37. package/src/sceneDebug.js +108 -0
  38. package/src/sceneDebug.test.js +128 -0
  39. package/src/session.js +76 -0
  40. package/src/session.test.js +91 -0
  41. package/src/viewfinder.js +47 -0
  42. package/src/viewfinder.test.js +87 -0
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@dfosco/storyboard-core",
3
+ "version": "1.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/dfosco/storyboard.git",
9
+ "directory": "packages/core"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "exports": {
15
+ ".": "./src/index.js",
16
+ "./comments": "./src/comments/index.js"
17
+ }
18
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Public API for comments — fetch, create, reply, resolve, move, delete, reactions.
3
+ *
4
+ * All functions assume comments config has been initialized via initCommentsConfig().
5
+ */
6
+
7
+ import { graphql } from './graphql.js'
8
+ import { getCommentsConfig } from './config.js'
9
+ import { parseMetadata, serializeMetadata, updateMetadata } from './metadata.js'
10
+ import {
11
+ SEARCH_DISCUSSION,
12
+ GET_CATEGORY_ID,
13
+ CREATE_DISCUSSION,
14
+ ADD_COMMENT,
15
+ ADD_REPLY,
16
+ UPDATE_COMMENT,
17
+ DELETE_COMMENT,
18
+ ADD_REACTION,
19
+ REMOVE_REACTION,
20
+ LIST_DISCUSSIONS,
21
+ } from './queries.js'
22
+
23
+ /**
24
+ * Fetch the discussion for a given route, including all comments and replies.
25
+ * Returns null if no discussion exists for the route.
26
+ * @param {string} route - The route path (e.g. "/Overview")
27
+ * @returns {Promise<object|null>}
28
+ */
29
+ export async function fetchRouteDiscussion(route) {
30
+ const config = getCommentsConfig()
31
+ const title = `Comments: ${route}`
32
+ const query = `"${title}" in:title repo:${config.repo.owner}/${config.repo.name}`
33
+
34
+ const data = await graphql(SEARCH_DISCUSSION, { query })
35
+ const discussion = data.search?.nodes?.[0]
36
+ if (!discussion) return null
37
+
38
+ // Parse metadata from each comment
39
+ const comments = (discussion.comments?.nodes ?? []).map((comment) => {
40
+ const { meta, text } = parseMetadata(comment.body)
41
+ return {
42
+ ...comment,
43
+ meta,
44
+ text,
45
+ replies: (comment.replies?.nodes ?? []).map((reply) => {
46
+ const { meta: replyMeta, text: replyText } = parseMetadata(reply.body)
47
+ return { ...reply, meta: replyMeta, text: replyText }
48
+ }),
49
+ }
50
+ })
51
+
52
+ return { ...discussion, comments }
53
+ }
54
+
55
+ /**
56
+ * Get the repository ID and discussion category ID.
57
+ * @returns {Promise<{ repositoryId: string, categoryId: string }>}
58
+ */
59
+ async function getRepoAndCategoryIds() {
60
+ const config = getCommentsConfig()
61
+ const categorySlug = config.discussions.category.toLowerCase().replace(/\s+/g, '-')
62
+ const data = await graphql(GET_CATEGORY_ID, {
63
+ owner: config.repo.owner,
64
+ name: config.repo.name,
65
+ slug: categorySlug,
66
+ })
67
+
68
+ const repositoryId = data.repository?.id
69
+ let categoryId = data.repository?.discussionCategory?.id
70
+
71
+ // Fallback: search by name in the list
72
+ if (!categoryId) {
73
+ const cat = data.repository?.discussionCategories?.nodes?.find(
74
+ (c) => c.name === config.discussions.category
75
+ )
76
+ categoryId = cat?.id
77
+ }
78
+
79
+ if (!repositoryId || !categoryId) {
80
+ throw new Error(
81
+ `Could not find repository or discussion category "${config.discussions.category}" in ${config.repo.owner}/${config.repo.name}`
82
+ )
83
+ }
84
+
85
+ return { repositoryId, categoryId }
86
+ }
87
+
88
+ /**
89
+ * Create a new comment on a route. Creates the route discussion if it doesn't exist.
90
+ * @param {string} route - The route path
91
+ * @param {number} x - X coordinate (percentage)
92
+ * @param {number} y - Y coordinate (percentage)
93
+ * @param {string} text - Comment text
94
+ * @returns {Promise<object>} - The created comment
95
+ */
96
+ export async function createComment(route, x, y, text) {
97
+ let discussion = await fetchRouteDiscussion(route)
98
+
99
+ if (!discussion) {
100
+ // Create the route discussion first
101
+ const { repositoryId, categoryId } = await getRepoAndCategoryIds()
102
+ const title = `Comments: ${route}`
103
+ const body = serializeMetadata({ route, createdAt: new Date().toISOString() }, '')
104
+ const result = await graphql(CREATE_DISCUSSION, { repositoryId, categoryId, title, body })
105
+ discussion = result.createDiscussion.discussion
106
+ }
107
+
108
+ const body = serializeMetadata({ x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }, text)
109
+ const result = await graphql(ADD_COMMENT, { discussionId: discussion.id, body })
110
+ return result.addDiscussionComment.comment
111
+ }
112
+
113
+ /**
114
+ * Reply to an existing comment.
115
+ * @param {string} discussionId - The discussion ID
116
+ * @param {string} commentId - The comment ID to reply to
117
+ * @param {string} text - Reply text
118
+ * @returns {Promise<object>}
119
+ */
120
+ export async function replyToComment(discussionId, commentId, text) {
121
+ const result = await graphql(ADD_REPLY, { discussionId, replyToId: commentId, body: text })
122
+ return result.addDiscussionComment.comment
123
+ }
124
+
125
+ /**
126
+ * Resolve a comment by updating its metadata.
127
+ * @param {string} commentId - The comment ID
128
+ * @param {string} currentBody - The current comment body
129
+ * @returns {Promise<object>}
130
+ */
131
+ export async function resolveComment(commentId, currentBody) {
132
+ const newBody = updateMetadata(currentBody, { resolved: true })
133
+ const result = await graphql(UPDATE_COMMENT, { commentId, body: newBody })
134
+ return result.updateDiscussionComment.comment
135
+ }
136
+
137
+ /**
138
+ * Move a comment to new coordinates.
139
+ * @param {string} commentId - The comment ID
140
+ * @param {string} currentBody - The current comment body
141
+ * @param {number} x - New X coordinate (percentage)
142
+ * @param {number} y - New Y coordinate (percentage)
143
+ * @returns {Promise<object>}
144
+ */
145
+ export async function moveComment(commentId, currentBody, x, y) {
146
+ const newBody = updateMetadata(currentBody, {
147
+ x: Math.round(x * 10) / 10,
148
+ y: Math.round(y * 10) / 10,
149
+ })
150
+ const result = await graphql(UPDATE_COMMENT, { commentId, body: newBody })
151
+ return result.updateDiscussionComment.comment
152
+ }
153
+
154
+ /**
155
+ * Delete a comment (typically a reply).
156
+ * @param {string} commentId - The comment ID to delete
157
+ * @returns {Promise<void>}
158
+ */
159
+ export async function deleteComment(commentId) {
160
+ await graphql(DELETE_COMMENT, { commentId })
161
+ }
162
+
163
+ /**
164
+ * Add a reaction to a comment or reply.
165
+ * @param {string} subjectId - The comment/reply ID
166
+ * @param {string} content - Reaction type (e.g. "THUMBS_UP", "HEART")
167
+ * @returns {Promise<void>}
168
+ */
169
+ export async function addReaction(subjectId, content) {
170
+ await graphql(ADD_REACTION, { subjectId, content })
171
+ }
172
+
173
+ /**
174
+ * Remove a reaction from a comment or reply.
175
+ * @param {string} subjectId - The comment/reply ID
176
+ * @param {string} content - Reaction type
177
+ * @returns {Promise<void>}
178
+ */
179
+ export async function removeReaction(subjectId, content) {
180
+ await graphql(REMOVE_REACTION, { subjectId, content })
181
+ }
182
+
183
+ /**
184
+ * List all comment discussions in the configured category.
185
+ * @returns {Promise<object[]>}
186
+ */
187
+ export async function listDiscussions() {
188
+ const config = getCommentsConfig()
189
+ const { categoryId } = await getRepoAndCategoryIds()
190
+ const data = await graphql(LIST_DISCUSSIONS, {
191
+ owner: config.repo.owner,
192
+ name: config.repo.name,
193
+ categoryId,
194
+ })
195
+ return data.repository?.discussions?.nodes ?? []
196
+ }
@@ -0,0 +1,194 @@
1
+ import {
2
+ fetchRouteDiscussion,
3
+ createComment,
4
+ replyToComment,
5
+ resolveComment,
6
+ moveComment,
7
+ addReaction,
8
+ removeReaction,
9
+ } from './api.js'
10
+ import { initCommentsConfig } from './config.js'
11
+ import { setToken, clearToken } from './auth.js'
12
+
13
+ // Mock the graphql module
14
+ vi.mock('./graphql.js', () => ({
15
+ graphql: vi.fn(),
16
+ }))
17
+
18
+ import { graphql } from './graphql.js'
19
+
20
+ describe('api', () => {
21
+ beforeEach(() => {
22
+ vi.clearAllMocks()
23
+ clearToken()
24
+ setToken('ghp_test')
25
+ initCommentsConfig({
26
+ comments: {
27
+ repo: { owner: 'dfosco', name: 'storyboard' },
28
+ discussions: { category: 'Storyboard Comments' },
29
+ },
30
+ })
31
+ })
32
+
33
+ describe('fetchRouteDiscussion', () => {
34
+ it('returns null when no discussion found', async () => {
35
+ graphql.mockResolvedValue({ search: { nodes: [] } })
36
+ const result = await fetchRouteDiscussion('/Overview')
37
+ expect(result).toBeNull()
38
+ })
39
+
40
+ it('parses comments with metadata', async () => {
41
+ graphql.mockResolvedValue({
42
+ search: {
43
+ nodes: [
44
+ {
45
+ id: 'D_123',
46
+ title: 'Comments: /Overview',
47
+ comments: {
48
+ nodes: [
49
+ {
50
+ id: 'C_1',
51
+ body: '<!-- sb-meta {"x":10,"y":20} -->\nHello',
52
+ createdAt: '2026-01-01T00:00:00Z',
53
+ author: { login: 'dfosco', avatarUrl: 'https://example.com' },
54
+ replies: { nodes: [] },
55
+ reactionGroups: [],
56
+ },
57
+ ],
58
+ },
59
+ },
60
+ ],
61
+ },
62
+ })
63
+
64
+ const result = await fetchRouteDiscussion('/Overview')
65
+ expect(result.id).toBe('D_123')
66
+ expect(result.comments).toHaveLength(1)
67
+ expect(result.comments[0].meta).toEqual({ x: 10, y: 20 })
68
+ expect(result.comments[0].text).toBe('Hello')
69
+ })
70
+
71
+ it('parses replies with metadata', async () => {
72
+ graphql.mockResolvedValue({
73
+ search: {
74
+ nodes: [
75
+ {
76
+ id: 'D_123',
77
+ comments: {
78
+ nodes: [
79
+ {
80
+ id: 'C_1',
81
+ body: '<!-- sb-meta {"x":10,"y":20} -->\nComment',
82
+ replies: {
83
+ nodes: [
84
+ {
85
+ id: 'R_1',
86
+ body: 'A reply',
87
+ author: { login: 'user2' },
88
+ },
89
+ ],
90
+ },
91
+ reactionGroups: [],
92
+ },
93
+ ],
94
+ },
95
+ },
96
+ ],
97
+ },
98
+ })
99
+
100
+ const result = await fetchRouteDiscussion('/Test')
101
+ expect(result.comments[0].replies).toHaveLength(1)
102
+ expect(result.comments[0].replies[0].text).toBe('A reply')
103
+ })
104
+ })
105
+
106
+ describe('createComment', () => {
107
+ it('creates a comment on existing discussion', async () => {
108
+ // First call: search for discussion
109
+ graphql.mockResolvedValueOnce({
110
+ search: {
111
+ nodes: [{ id: 'D_123', comments: { nodes: [] } }],
112
+ },
113
+ })
114
+ // Second call: add comment
115
+ graphql.mockResolvedValueOnce({
116
+ addDiscussionComment: {
117
+ comment: { id: 'C_new', body: '<!-- sb-meta {"x":50,"y":60} -->\nTest' },
118
+ },
119
+ })
120
+
121
+ const result = await createComment('/Overview', 50, 60, 'Test')
122
+ expect(result.id).toBe('C_new')
123
+ expect(graphql).toHaveBeenCalledTimes(2)
124
+ })
125
+ })
126
+
127
+ describe('replyToComment', () => {
128
+ it('posts a reply', async () => {
129
+ graphql.mockResolvedValue({
130
+ addDiscussionComment: {
131
+ comment: { id: 'R_1', body: 'Reply text' },
132
+ },
133
+ })
134
+
135
+ const result = await replyToComment('D_123', 'C_1', 'Reply text')
136
+ expect(result.id).toBe('R_1')
137
+ })
138
+ })
139
+
140
+ describe('resolveComment', () => {
141
+ it('updates metadata with resolved flag', async () => {
142
+ graphql.mockResolvedValue({
143
+ updateDiscussionComment: {
144
+ comment: { id: 'C_1', body: '<!-- sb-meta {"x":10,"y":20,"resolved":true} -->\nText' },
145
+ },
146
+ })
147
+
148
+ const body = '<!-- sb-meta {"x":10,"y":20} -->\nText'
149
+ await resolveComment('C_1', body)
150
+
151
+ const calledBody = graphql.mock.calls[0][1].body
152
+ expect(calledBody).toContain('"resolved":true')
153
+ })
154
+ })
155
+
156
+ describe('moveComment', () => {
157
+ it('updates coordinates in metadata', async () => {
158
+ graphql.mockResolvedValue({
159
+ updateDiscussionComment: {
160
+ comment: { id: 'C_1', body: 'updated' },
161
+ },
162
+ })
163
+
164
+ const body = '<!-- sb-meta {"x":10,"y":20} -->\nText'
165
+ await moveComment('C_1', body, 50, 60)
166
+
167
+ const calledBody = graphql.mock.calls[0][1].body
168
+ expect(calledBody).toContain('"x":50')
169
+ expect(calledBody).toContain('"y":60')
170
+ })
171
+ })
172
+
173
+ describe('addReaction', () => {
174
+ it('calls graphql with correct args', async () => {
175
+ graphql.mockResolvedValue({})
176
+ await addReaction('C_1', 'HEART')
177
+ expect(graphql).toHaveBeenCalledWith(
178
+ expect.stringContaining('addReaction'),
179
+ { subjectId: 'C_1', content: 'HEART' }
180
+ )
181
+ })
182
+ })
183
+
184
+ describe('removeReaction', () => {
185
+ it('calls graphql with correct args', async () => {
186
+ graphql.mockResolvedValue({})
187
+ await removeReaction('C_1', 'THUMBS_UP')
188
+ expect(graphql).toHaveBeenCalledWith(
189
+ expect.stringContaining('removeReaction'),
190
+ { subjectId: 'C_1', content: 'THUMBS_UP' }
191
+ )
192
+ })
193
+ })
194
+ })
@@ -0,0 +1,79 @@
1
+ /**
2
+ * PAT authentication for comments.
3
+ *
4
+ * Stores and retrieves the GitHub PAT from localStorage.
5
+ * Provides validation by fetching the authenticated user.
6
+ */
7
+
8
+ const STORAGE_KEY = 'sb-comments-token'
9
+ const USER_KEY = 'sb-comments-user'
10
+
11
+ /**
12
+ * Get the stored PAT token.
13
+ * @returns {string|null}
14
+ */
15
+ export function getToken() {
16
+ try {
17
+ return localStorage.getItem(STORAGE_KEY)
18
+ } catch {
19
+ return null
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Store a PAT token.
25
+ * @param {string} token
26
+ */
27
+ export function setToken(token) {
28
+ localStorage.setItem(STORAGE_KEY, token)
29
+ }
30
+
31
+ /**
32
+ * Remove the stored PAT token and user.
33
+ */
34
+ export function clearToken() {
35
+ localStorage.removeItem(STORAGE_KEY)
36
+ localStorage.removeItem(USER_KEY)
37
+ }
38
+
39
+ /**
40
+ * Get the cached authenticated user info.
41
+ * @returns {{ login: string, avatarUrl: string }|null}
42
+ */
43
+ export function getCachedUser() {
44
+ try {
45
+ const raw = localStorage.getItem(USER_KEY)
46
+ return raw ? JSON.parse(raw) : null
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Validate a PAT by fetching the authenticated user from GitHub.
54
+ * Caches the result in localStorage on success.
55
+ * @param {string} token - GitHub PAT to validate
56
+ * @returns {Promise<{ login: string, avatarUrl: string }>}
57
+ */
58
+ export async function validateToken(token) {
59
+ const res = await fetch('https://api.github.com/user', {
60
+ headers: { Authorization: `bearer ${token}` },
61
+ })
62
+
63
+ if (!res.ok) {
64
+ throw new Error('Invalid token — GitHub returned ' + res.status)
65
+ }
66
+
67
+ const user = await res.json()
68
+ const userInfo = { login: user.login, avatarUrl: user.avatar_url }
69
+ localStorage.setItem(USER_KEY, JSON.stringify(userInfo))
70
+ return userInfo
71
+ }
72
+
73
+ /**
74
+ * Check whether the user is currently authenticated.
75
+ * @returns {boolean}
76
+ */
77
+ export function isAuthenticated() {
78
+ return getToken() !== null
79
+ }
@@ -0,0 +1,60 @@
1
+ import { getToken, setToken, clearToken, getCachedUser, isAuthenticated } from './auth.js'
2
+
3
+ describe('auth token management', () => {
4
+ beforeEach(() => {
5
+ localStorage.clear()
6
+ })
7
+
8
+ it('returns null when no token is stored', () => {
9
+ expect(getToken()).toBeNull()
10
+ })
11
+
12
+ it('stores and retrieves a token', () => {
13
+ setToken('ghp_test123')
14
+ expect(getToken()).toBe('ghp_test123')
15
+ })
16
+
17
+ it('clears token and user', () => {
18
+ setToken('ghp_test123')
19
+ localStorage.setItem('sb-comments-user', JSON.stringify({ login: 'test' }))
20
+ clearToken()
21
+ expect(getToken()).toBeNull()
22
+ expect(getCachedUser()).toBeNull()
23
+ })
24
+ })
25
+
26
+ describe('getCachedUser', () => {
27
+ beforeEach(() => {
28
+ localStorage.clear()
29
+ })
30
+
31
+ it('returns null when no user is cached', () => {
32
+ expect(getCachedUser()).toBeNull()
33
+ })
34
+
35
+ it('returns cached user info', () => {
36
+ const user = { login: 'dfosco', avatarUrl: 'https://example.com/avatar.png' }
37
+ localStorage.setItem('sb-comments-user', JSON.stringify(user))
38
+ expect(getCachedUser()).toEqual(user)
39
+ })
40
+
41
+ it('returns null on invalid JSON', () => {
42
+ localStorage.setItem('sb-comments-user', 'not json')
43
+ expect(getCachedUser()).toBeNull()
44
+ })
45
+ })
46
+
47
+ describe('isAuthenticated', () => {
48
+ beforeEach(() => {
49
+ localStorage.clear()
50
+ })
51
+
52
+ it('returns false when no token', () => {
53
+ expect(isAuthenticated()).toBe(false)
54
+ })
55
+
56
+ it('returns true when token exists', () => {
57
+ setToken('ghp_abc')
58
+ expect(isAuthenticated()).toBe(true)
59
+ })
60
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Comment mode state — manages the toggle between normal and comment mode.
3
+ *
4
+ * When active: cursor changes to crosshair, comment pins are visible,
5
+ * clicking places a new comment.
6
+ */
7
+
8
+ import { isCommentsEnabled } from './config.js'
9
+ import { isAuthenticated } from './auth.js'
10
+
11
+ let _active = false
12
+ const _listeners = new Set()
13
+
14
+ /**
15
+ * Check whether comment mode is currently active.
16
+ */
17
+ export function isCommentModeActive() {
18
+ return _active
19
+ }
20
+
21
+ /**
22
+ * Toggle comment mode on/off.
23
+ * Only activates if comments are enabled and user is authenticated.
24
+ * @returns {boolean} The new state
25
+ */
26
+ export function toggleCommentMode() {
27
+ if (!isCommentsEnabled()) {
28
+ console.warn('[storyboard] Comments not enabled — check storyboard.config.json')
29
+ return false
30
+ }
31
+
32
+ if (!_active && !isAuthenticated()) {
33
+ console.warn('[storyboard] Sign in first to use comments')
34
+ return false
35
+ }
36
+
37
+ _active = !_active
38
+ _notify()
39
+ return _active
40
+ }
41
+
42
+ /**
43
+ * Explicitly set comment mode.
44
+ * @param {boolean} active
45
+ */
46
+ export function setCommentMode(active) {
47
+ _active = active
48
+ _notify()
49
+ }
50
+
51
+ /**
52
+ * Subscribe to comment mode changes.
53
+ * @param {(active: boolean) => void} callback
54
+ * @returns {() => void} Unsubscribe function
55
+ */
56
+ export function subscribeToCommentMode(callback) {
57
+ _listeners.add(callback)
58
+ return () => _listeners.delete(callback)
59
+ }
60
+
61
+ function _notify() {
62
+ for (const cb of _listeners) cb(_active)
63
+ }
@@ -0,0 +1,87 @@
1
+ import {
2
+ isCommentModeActive,
3
+ toggleCommentMode,
4
+ setCommentMode,
5
+ subscribeToCommentMode,
6
+ } from './commentMode.js'
7
+ import { initCommentsConfig } from './config.js'
8
+ import { setToken, clearToken } from './auth.js'
9
+
10
+ describe('commentMode', () => {
11
+ beforeEach(() => {
12
+ // Reset state
13
+ setCommentMode(false)
14
+ clearToken()
15
+ initCommentsConfig(null)
16
+ })
17
+
18
+ it('starts inactive', () => {
19
+ setCommentMode(false)
20
+ expect(isCommentModeActive()).toBe(false)
21
+ })
22
+
23
+ it('setCommentMode activates and deactivates', () => {
24
+ setCommentMode(true)
25
+ expect(isCommentModeActive()).toBe(true)
26
+ setCommentMode(false)
27
+ expect(isCommentModeActive()).toBe(false)
28
+ })
29
+
30
+ it('toggleCommentMode returns false when comments not enabled', () => {
31
+ const result = toggleCommentMode()
32
+ expect(result).toBe(false)
33
+ expect(isCommentModeActive()).toBe(false)
34
+ })
35
+
36
+ it('toggleCommentMode returns false when not authenticated', () => {
37
+ initCommentsConfig({
38
+ comments: { repo: { owner: 'o', name: 'r' } },
39
+ })
40
+ const result = toggleCommentMode()
41
+ expect(result).toBe(false)
42
+ expect(isCommentModeActive()).toBe(false)
43
+ })
44
+
45
+ it('toggleCommentMode activates when enabled and authenticated', () => {
46
+ initCommentsConfig({
47
+ comments: { repo: { owner: 'o', name: 'r' } },
48
+ })
49
+ setToken('ghp_test')
50
+ const result = toggleCommentMode()
51
+ expect(result).toBe(true)
52
+ expect(isCommentModeActive()).toBe(true)
53
+ })
54
+
55
+ it('toggleCommentMode toggles off when active', () => {
56
+ initCommentsConfig({
57
+ comments: { repo: { owner: 'o', name: 'r' } },
58
+ })
59
+ setToken('ghp_test')
60
+ toggleCommentMode() // on
61
+ const result = toggleCommentMode() // off
62
+ expect(result).toBe(false)
63
+ expect(isCommentModeActive()).toBe(false)
64
+ })
65
+
66
+ it('subscribeToCommentMode calls callback on changes', () => {
67
+ const calls = []
68
+ subscribeToCommentMode((active) => calls.push(active))
69
+
70
+ setCommentMode(true)
71
+ setCommentMode(false)
72
+ setCommentMode(true)
73
+
74
+ expect(calls).toEqual([true, false, true])
75
+ })
76
+
77
+ it('subscribeToCommentMode returns unsubscribe function', () => {
78
+ const calls = []
79
+ const unsub = subscribeToCommentMode((active) => calls.push(active))
80
+
81
+ setCommentMode(true)
82
+ unsub()
83
+ setCommentMode(false)
84
+
85
+ expect(calls).toEqual([true])
86
+ })
87
+ })