@dfosco/storyboard-core 1.10.0 → 1.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,6 +9,8 @@ import { getCommentsConfig } from './config.js'
9
9
  import { parseMetadata, serializeMetadata, updateMetadata } from './metadata.js'
10
10
  import {
11
11
  SEARCH_DISCUSSION,
12
+ SEARCH_DISCUSSION_LIGHTWEIGHT,
13
+ GET_COMMENT_DETAIL,
12
14
  GET_CATEGORY_ID,
13
15
  CREATE_DISCUSSION,
14
16
  ADD_COMMENT,
@@ -52,6 +54,48 @@ export async function fetchRouteDiscussion(route) {
52
54
  return { ...discussion, comments }
53
55
  }
54
56
 
57
+ /**
58
+ * Fetch lightweight comment listing for a route (pins only — no replies, no reactions).
59
+ * Returns null if no discussion exists for the route.
60
+ * @param {string} route - The route path (e.g. "/Overview")
61
+ * @returns {Promise<object|null>}
62
+ */
63
+ export async function fetchRouteCommentsSummary(route) {
64
+ const config = getCommentsConfig()
65
+ const title = `Comments: ${route}`
66
+ const query = `"${title}" in:title repo:${config.repo.owner}/${config.repo.name}`
67
+
68
+ const data = await graphql(SEARCH_DISCUSSION_LIGHTWEIGHT, { query })
69
+ const discussion = data.search?.nodes?.[0]
70
+ if (!discussion) return null
71
+
72
+ const comments = (discussion.comments?.nodes ?? []).map((comment) => {
73
+ const { meta, text } = parseMetadata(comment.body)
74
+ return { ...comment, meta, text }
75
+ })
76
+
77
+ return { ...discussion, comments }
78
+ }
79
+
80
+ /**
81
+ * Fetch full detail for a single comment (replies, reactions, etc.).
82
+ * @param {string} commentId - The GraphQL node ID of the comment
83
+ * @returns {Promise<object|null>}
84
+ */
85
+ export async function fetchCommentDetail(commentId) {
86
+ const data = await graphql(GET_COMMENT_DETAIL, { id: commentId })
87
+ const node = data.node
88
+ if (!node) return null
89
+
90
+ const { meta, text } = parseMetadata(node.body)
91
+ const replies = (node.replies?.nodes ?? []).map((reply) => {
92
+ const { meta: replyMeta, text: replyText } = parseMetadata(reply.body)
93
+ return { ...reply, meta: replyMeta, text: replyText }
94
+ })
95
+
96
+ return { ...node, meta, text, replies }
97
+ }
98
+
55
99
  /**
56
100
  * Get the repository ID and discussion category ID.
57
101
  * @returns {Promise<{ repositoryId: string, categoryId: string }>}
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  fetchRouteDiscussion,
3
+ fetchRouteCommentsSummary,
4
+ fetchCommentDetail,
3
5
  createComment,
4
6
  replyToComment,
5
7
  resolveComment,
@@ -103,6 +105,92 @@ describe('api', () => {
103
105
  })
104
106
  })
105
107
 
108
+ describe('fetchRouteCommentsSummary', () => {
109
+ it('returns null when no discussion found', async () => {
110
+ graphql.mockResolvedValue({ search: { nodes: [] } })
111
+ const result = await fetchRouteCommentsSummary('/Overview')
112
+ expect(result).toBeNull()
113
+ })
114
+
115
+ it('parses comments with metadata (no replies/reactions)', async () => {
116
+ graphql.mockResolvedValue({
117
+ search: {
118
+ nodes: [
119
+ {
120
+ id: 'D_123',
121
+ title: 'Comments: /Overview',
122
+ comments: {
123
+ nodes: [
124
+ {
125
+ id: 'C_1',
126
+ body: '<!-- sb-meta {"x":10,"y":20} -->\nHello',
127
+ author: { login: 'dfosco', avatarUrl: 'https://example.com' },
128
+ },
129
+ ],
130
+ },
131
+ },
132
+ ],
133
+ },
134
+ })
135
+
136
+ const result = await fetchRouteCommentsSummary('/Overview')
137
+ expect(result.id).toBe('D_123')
138
+ expect(result.comments).toHaveLength(1)
139
+ expect(result.comments[0].meta).toEqual({ x: 10, y: 20 })
140
+ expect(result.comments[0].text).toBe('Hello')
141
+ // Should use lightweight query (no replies/reactions in query)
142
+ expect(graphql).toHaveBeenCalledWith(
143
+ expect.stringContaining('SearchDiscussionLightweight'),
144
+ expect.any(Object)
145
+ )
146
+ })
147
+ })
148
+
149
+ describe('fetchCommentDetail', () => {
150
+ it('returns null when node not found', async () => {
151
+ graphql.mockResolvedValue({ node: null })
152
+ const result = await fetchCommentDetail('C_999')
153
+ expect(result).toBeNull()
154
+ })
155
+
156
+ it('returns full comment with replies and reactions', async () => {
157
+ graphql.mockResolvedValue({
158
+ node: {
159
+ id: 'C_1',
160
+ body: '<!-- sb-meta {"x":10,"y":20} -->\nHello',
161
+ createdAt: '2026-01-01T00:00:00Z',
162
+ author: { login: 'dfosco', avatarUrl: 'https://example.com' },
163
+ replies: {
164
+ nodes: [
165
+ {
166
+ id: 'R_1',
167
+ body: 'A reply',
168
+ createdAt: '2026-01-02T00:00:00Z',
169
+ author: { login: 'user2', avatarUrl: 'https://example.com/2' },
170
+ reactionGroups: [],
171
+ },
172
+ ],
173
+ },
174
+ reactionGroups: [
175
+ { content: 'HEART', users: { totalCount: 1 }, viewerHasReacted: true },
176
+ ],
177
+ },
178
+ })
179
+
180
+ const result = await fetchCommentDetail('C_1')
181
+ expect(result.id).toBe('C_1')
182
+ expect(result.meta).toEqual({ x: 10, y: 20 })
183
+ expect(result.text).toBe('Hello')
184
+ expect(result.replies).toHaveLength(1)
185
+ expect(result.replies[0].text).toBe('A reply')
186
+ expect(result.reactionGroups).toHaveLength(1)
187
+ expect(graphql).toHaveBeenCalledWith(
188
+ expect.stringContaining('GetCommentDetail'),
189
+ { id: 'C_1' }
190
+ )
191
+ })
192
+ })
193
+
106
194
  describe('createComment', () => {
107
195
  it('creates a comment on existing discussion', async () => {
108
196
  // First call: search for discussion
@@ -0,0 +1,55 @@
1
+ /**
2
+ * localStorage cache for comment pin data.
3
+ *
4
+ * Stores lightweight comment listings per route so pins render instantly
5
+ * on repeat visits without hitting the GitHub API.
6
+ */
7
+
8
+ const CACHE_PREFIX = 'sb-comments:'
9
+ const TTL_MS = 2 * 60 * 1000 // 2 minutes
10
+
11
+ /**
12
+ * Get cached comment listing for a route.
13
+ * Returns null if cache is missing or expired.
14
+ * @param {string} route
15
+ * @returns {object|null} - Cached discussion object (with .comments array)
16
+ */
17
+ export function getCachedComments(route) {
18
+ try {
19
+ const raw = localStorage.getItem(CACHE_PREFIX + route)
20
+ if (!raw) return null
21
+ const entry = JSON.parse(raw)
22
+ if (Date.now() - entry.ts > TTL_MS) {
23
+ localStorage.removeItem(CACHE_PREFIX + route)
24
+ return null
25
+ }
26
+ return entry.data
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Store comment listing in cache for a route.
34
+ * @param {string} route
35
+ * @param {object} data - Discussion object with .comments array
36
+ */
37
+ export function setCachedComments(route, data) {
38
+ try {
39
+ localStorage.setItem(CACHE_PREFIX + route, JSON.stringify({ ts: Date.now(), data }))
40
+ } catch {
41
+ // localStorage full or unavailable — ignore
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Clear cached comments for a specific route.
47
+ * @param {string} route
48
+ */
49
+ export function clearCachedComments(route) {
50
+ try {
51
+ localStorage.removeItem(CACHE_PREFIX + route)
52
+ } catch {
53
+ // ignore
54
+ }
55
+ }
@@ -0,0 +1,48 @@
1
+ import { getCachedComments, setCachedComments, clearCachedComments } from './commentCache.js'
2
+
3
+ describe('commentCache', () => {
4
+ beforeEach(() => {
5
+ localStorage.clear()
6
+ })
7
+
8
+ it('returns null for uncached route', () => {
9
+ expect(getCachedComments('/Overview')).toBeNull()
10
+ })
11
+
12
+ it('stores and retrieves cached comments', () => {
13
+ const data = { id: 'D_1', comments: [{ id: 'C_1', meta: { x: 10, y: 20 } }] }
14
+ setCachedComments('/Overview', data)
15
+ const cached = getCachedComments('/Overview')
16
+ expect(cached).toEqual(data)
17
+ })
18
+
19
+ it('returns null for expired cache', () => {
20
+ const data = { id: 'D_1', comments: [] }
21
+ // Manually write expired entry
22
+ localStorage.setItem('sb-comments:/Overview', JSON.stringify({
23
+ ts: Date.now() - 3 * 60 * 1000, // 3 min ago (exceeds 2-min TTL)
24
+ data,
25
+ }))
26
+ expect(getCachedComments('/Overview')).toBeNull()
27
+ })
28
+
29
+ it('clears cached comments for a route', () => {
30
+ setCachedComments('/Overview', { id: 'D_1', comments: [] })
31
+ clearCachedComments('/Overview')
32
+ expect(getCachedComments('/Overview')).toBeNull()
33
+ })
34
+
35
+ it('does not affect other routes when clearing', () => {
36
+ const data1 = { id: 'D_1', comments: [] }
37
+ const data2 = { id: 'D_2', comments: [] }
38
+ setCachedComments('/Overview', data1)
39
+ setCachedComments('/Issues', data2)
40
+ clearCachedComments('/Overview')
41
+ expect(getCachedComments('/Issues')).toEqual(data2)
42
+ })
43
+
44
+ it('handles corrupted localStorage gracefully', () => {
45
+ localStorage.setItem('sb-comments:/Overview', 'not-json')
46
+ expect(getCachedComments('/Overview')).toBeNull()
47
+ })
48
+ })
@@ -17,6 +17,8 @@ export { parseMetadata, serializeMetadata, updateMetadata } from './metadata.js'
17
17
  // API
18
18
  export {
19
19
  fetchRouteDiscussion,
20
+ fetchRouteCommentsSummary,
21
+ fetchCommentDetail,
20
22
  createComment,
21
23
  replyToComment,
22
24
  resolveComment,
@@ -30,6 +32,9 @@ export {
30
32
  listDiscussions,
31
33
  } from './api.js'
32
34
 
35
+ // Cache
36
+ export { getCachedComments, setCachedComments, clearCachedComments } from './commentCache.js'
37
+
33
38
  // GraphQL client (for advanced use)
34
39
  export { graphql } from './graphql.js'
35
40
 
@@ -2,7 +2,7 @@
2
2
  * GraphQL query and mutation strings for the comments system.
3
3
  */
4
4
 
5
- /** Search for a discussion by title in a repo */
5
+ /** Search for a discussion by title full data (used by drawer) */
6
6
  export const SEARCH_DISCUSSION = `
7
7
  query SearchDiscussion($query: String!) {
8
8
  search(query: $query, type: DISCUSSION, first: 1) {
@@ -50,6 +50,69 @@ export const SEARCH_DISCUSSION = `
50
50
  }
51
51
  `
52
52
 
53
+ /** Search for a discussion — lightweight (pins only: id, body for metadata, author) */
54
+ export const SEARCH_DISCUSSION_LIGHTWEIGHT = `
55
+ query SearchDiscussionLightweight($query: String!) {
56
+ search(query: $query, type: DISCUSSION, first: 1) {
57
+ nodes {
58
+ ... on Discussion {
59
+ id
60
+ title
61
+ url
62
+ comments(first: 100) {
63
+ nodes {
64
+ id
65
+ body
66
+ author {
67
+ login
68
+ avatarUrl
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ `
77
+
78
+ /** Fetch full detail for a single comment by node ID */
79
+ export const GET_COMMENT_DETAIL = `
80
+ query GetCommentDetail($id: ID!) {
81
+ node(id: $id) {
82
+ ... on DiscussionComment {
83
+ id
84
+ body
85
+ createdAt
86
+ author {
87
+ login
88
+ avatarUrl
89
+ }
90
+ replies(first: 50) {
91
+ nodes {
92
+ id
93
+ body
94
+ createdAt
95
+ author {
96
+ login
97
+ avatarUrl
98
+ }
99
+ reactionGroups {
100
+ content
101
+ users(first: 0) { totalCount }
102
+ viewerHasReacted
103
+ }
104
+ }
105
+ }
106
+ reactionGroups {
107
+ content
108
+ users(first: 0) { totalCount }
109
+ viewerHasReacted
110
+ }
111
+ }
112
+ }
113
+ }
114
+ `
115
+
53
116
  /** Get the discussion category ID by name */
54
117
  export const GET_CATEGORY_ID = `
55
118
  query GetCategoryId($owner: String!, $name: String!, $slug: String!) {
@@ -21,30 +21,29 @@ export function openAuthModal() {
21
21
  const backdrop = document.createElement('div')
22
22
  backdrop.id = MODAL_ID
23
23
  backdrop.className = 'sb-auth-backdrop fixed top-0 right-0 bottom-0 left-0 flex items-center justify-center sans-serif'
24
- backdrop.style.cssText = 'z-index:100000;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);'
25
24
 
26
25
  backdrop.innerHTML = `
27
- <div class="sb-bg ba sb-b-default br3 sb-shadow sb-fg overflow-hidden" style="width:420px;max-width:calc(100vw - 32px)" x-data="sbAuthModal">
26
+ <div class="sb-auth-modal sb-bg ba sb-b-default br3 sb-shadow sb-fg overflow-hidden" x-data="sbAuthModal">
28
27
  <div class="flex items-center justify-between ph4 pv3 bb sb-b-muted">
29
28
  <h2 class="ma0 f5 fw6 sb-fg">Sign in for comments</h2>
30
- <button class="flex items-center justify-center bg-transparent bn br2 sb-fg-muted pointer" style="width:28px;height:28px;font-size:18px;line-height:1" @click="close()" aria-label="Close">×</button>
29
+ <button class="flex items-center justify-center bg-transparent bn br2 sb-fg-muted pointer sb-close-btn" @click="close()" aria-label="Close">×</button>
31
30
  </div>
32
31
  <div class="pa4">
33
- <p class="ma0 mb3 lh-copy sb-fg-muted" style="font-size:13px">
32
+ <p class="ma0 mb3 lh-copy sb-fg-muted sb-f-sm">
34
33
  Enter a <a class="sb-fg-accent no-underline" href="https://github.com/settings/tokens/new" target="_blank" rel="noopener">GitHub Personal Access Token</a>
35
34
  to leave comments on this prototype. Your token is stored locally in your browser.
36
35
  </p>
37
- <label class="db mb1 fw5 sb-fg" style="font-size:13px" for="sb-auth-token-input">Personal Access Token</label>
38
- <input class="sb-input w-100 ph3 pv2 br2 f6 code db" style="box-sizing:border-box" id="sb-auth-token-input" type="password"
36
+ <label class="db mb1 fw5 sb-fg sb-f-sm" for="sb-auth-token-input">Personal Access Token</label>
37
+ <input class="sb-input w-100 ph3 pv2 br2 f6 code db" id="sb-auth-token-input" type="password"
39
38
  placeholder="ghp_xxxxxxxxxxxx" autocomplete="off" spellcheck="false"
40
39
  x-model="token" @keydown.enter="submit()" />
41
- <div class="mt2 ph3 pv2 sb-bg-inset ba sb-b-muted br2 f7 sb-fg-muted lh-copy">Required scopes: <code class="dib ph1 sb-bg-muted br1 code sb-fg" style="font-size:11px;padding-top:1px;padding-bottom:1px">repo</code> <code class="dib ph1 sb-bg-muted br1 code sb-fg" style="font-size:11px;padding-top:1px;padding-bottom:1px">read:user</code></div>
40
+ <div class="mt2 ph3 pv2 sb-bg-inset ba sb-b-muted br2 f7 sb-fg-muted lh-copy">Required scopes: <code class="dib ph1 sb-bg-muted br1 code sb-fg sb-code-badge">repo</code> <code class="dib ph1 sb-bg-muted br1 code sb-fg sb-code-badge">read:user</code></div>
42
41
  <template x-if="error">
43
- <div class="mt2 ph3 pv2 br2 sb-fg-danger" style="font-size:13px;background:color-mix(in srgb, var(--sb-fg-danger) 10%, transparent);border:1px solid color-mix(in srgb, var(--sb-fg-danger) 30%, transparent)" x-text="error"></div>
42
+ <div class="mt2 ph3 pv2 br2 sb-fg-danger sb-f-sm sb-error-alert" x-text="error"></div>
44
43
  </template>
45
44
  <template x-if="user">
46
45
  <div class="flex items-center pv1">
47
- <img class="br-100 ba sb-b-default mr3" style="width:40px;height:40px;border-width:2px" :src="user.avatarUrl" :alt="user.login" />
46
+ <img class="br-100 ba sb-b-default mr3 sb-avatar-lg" :src="user.avatarUrl" :alt="user.login" />
48
47
  <div class="f6 sb-fg">
49
48
  <span x-text="user.login"></span>
50
49
  <span class="db f7 sb-fg-success mt1">✓ Signed in</span>
@@ -53,8 +52,8 @@ export function openAuthModal() {
53
52
  </template>
54
53
  </div>
55
54
  <div class="flex items-center justify-end ph4 pv3 bt sb-b-muted">
56
- <button class="sb-btn-cancel ph3 pv1 br2 fw5 sans-serif pointer mr2" style="font-size:13px" @click="close()">Cancel</button>
57
- <button class="sb-btn-success ph3 pv1 br2 fw5 sans-serif pointer bn" style="font-size:13px" :disabled="submitting"
55
+ <button class="sb-btn-cancel ph3 pv1 br2 fw5 sans-serif pointer mr2 sb-f-sm" @click="close()">Cancel</button>
56
+ <button class="sb-btn-success ph3 pv1 br2 fw5 sans-serif pointer bn sb-f-sm" :disabled="submitting"
58
57
  @click="user ? done() : submit()" x-text="user ? 'Done' : (submitting ? 'Validating…' : 'Sign in')">
59
58
  Sign in
60
59
  </button>