@flamingo-stack/openframe-frontend-core 0.0.565-1861.5100.1 → 0.0.565

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 (204) hide show
  1. package/dist/chat-protocol/index.cjs.map +1 -1
  2. package/dist/chat-protocol/index.js.map +1 -1
  3. package/dist/{chunk-TA5P2M7V.cjs → chunk-26LZFWY6.cjs} +732 -812
  4. package/dist/chunk-26LZFWY6.cjs.map +1 -0
  5. package/dist/{chunk-KHEOXHF4.cjs → chunk-32QBTN5L.cjs} +5 -5
  6. package/dist/{chunk-KHEOXHF4.cjs.map → chunk-32QBTN5L.cjs.map} +1 -1
  7. package/dist/{chunk-GG6CAELN.js → chunk-3FBPPKV2.js} +4 -4
  8. package/dist/{chunk-MSUSONUK.js → chunk-5JYX2GOP.js} +3233 -3313
  9. package/dist/chunk-5JYX2GOP.js.map +1 -0
  10. package/dist/{chunk-EQEMZU45.js → chunk-5K3F5AZX.js} +4 -4
  11. package/dist/{chunk-YVOMHB73.js → chunk-5MVE77D5.js} +21 -35
  12. package/dist/chunk-5MVE77D5.js.map +1 -0
  13. package/dist/{chunk-H4RCJ4IB.js → chunk-6F47STCR.js} +3 -7
  14. package/dist/{chunk-H4RCJ4IB.js.map → chunk-6F47STCR.js.map} +1 -1
  15. package/dist/{chunk-XPLZB74T.js → chunk-6RILRC7X.js} +2 -2
  16. package/dist/{chunk-BKDSNVGH.cjs → chunk-7FH6MZ6M.cjs} +9 -9
  17. package/dist/{chunk-BKDSNVGH.cjs.map → chunk-7FH6MZ6M.cjs.map} +1 -1
  18. package/dist/{chunk-ZUVVJ24E.js → chunk-7IR5LKYC.js} +2 -2
  19. package/dist/{chunk-GG2HJQW3.cjs → chunk-AN7YK35P.cjs} +26 -26
  20. package/dist/{chunk-GG2HJQW3.cjs.map → chunk-AN7YK35P.cjs.map} +1 -1
  21. package/dist/{chunk-PZH3CZTJ.cjs → chunk-CCFBL7JU.cjs} +75 -89
  22. package/dist/chunk-CCFBL7JU.cjs.map +1 -0
  23. package/dist/{chunk-OEN3GTRL.js → chunk-DEYRHTLD.js} +2 -2
  24. package/dist/{chunk-MIKGZY7V.js → chunk-GCMASPSW.js} +5 -5
  25. package/dist/{chunk-ZYC4O76Y.js → chunk-HJ7OVIR7.js} +2 -2
  26. package/dist/{chunk-BWMVI2KD.cjs → chunk-HVS56KQE.cjs} +7 -7
  27. package/dist/{chunk-BWMVI2KD.cjs.map → chunk-HVS56KQE.cjs.map} +1 -1
  28. package/dist/{chunk-GZL5SAUI.cjs → chunk-HWQ5HMJN.cjs} +15 -19
  29. package/dist/chunk-HWQ5HMJN.cjs.map +1 -0
  30. package/dist/{chunk-NAYXROCV.cjs → chunk-ISK6LRRY.cjs} +3 -3
  31. package/dist/{chunk-NAYXROCV.cjs.map → chunk-ISK6LRRY.cjs.map} +1 -1
  32. package/dist/{chunk-4H7L4OSR.cjs → chunk-LOLVMAT3.cjs} +4 -4
  33. package/dist/{chunk-4H7L4OSR.cjs.map → chunk-LOLVMAT3.cjs.map} +1 -1
  34. package/dist/{chunk-XZNKPEWB.js → chunk-MIBKEMZY.js} +2 -2
  35. package/dist/chunk-MVWSLZG7.js +2 -0
  36. package/dist/{chunk-QWVON3LG.cjs → chunk-NCJUIT3Q.cjs} +20 -20
  37. package/dist/{chunk-QWVON3LG.cjs.map → chunk-NCJUIT3Q.cjs.map} +1 -1
  38. package/dist/{chunk-OTDVQBLI.cjs → chunk-NM5NBMMD.cjs} +31 -31
  39. package/dist/{chunk-OTDVQBLI.cjs.map → chunk-NM5NBMMD.cjs.map} +1 -1
  40. package/dist/{chunk-SQ53GVCG.js → chunk-QQFUJ6MS.js} +2 -2
  41. package/dist/{chunk-PBH2BQYR.cjs → chunk-TFEL2C6I.cjs} +86 -86
  42. package/dist/{chunk-PBH2BQYR.cjs.map → chunk-TFEL2C6I.cjs.map} +1 -1
  43. package/dist/{chunk-DLSDYLY3.cjs → chunk-TFRJ7B4X.cjs} +11 -11
  44. package/dist/{chunk-DLSDYLY3.cjs.map → chunk-TFRJ7B4X.cjs.map} +1 -1
  45. package/dist/{chunk-Y6IGG6RZ.cjs → chunk-TGT3JHVU.cjs} +39 -39
  46. package/dist/{chunk-Y6IGG6RZ.cjs.map → chunk-TGT3JHVU.cjs.map} +1 -1
  47. package/dist/{chunk-XSK7ZQ3C.js → chunk-TXLUKQBI.js} +2 -2
  48. package/dist/{chunk-LOWWLH2W.js → chunk-UK6SSMUR.js} +2 -2
  49. package/dist/chunk-ZTM35Q76.cjs +2 -0
  50. package/dist/{chunk-UGAHDPNU.cjs.map → chunk-ZTM35Q76.cjs.map} +1 -1
  51. package/dist/components/case-studies/index.cjs +8 -8
  52. package/dist/components/case-studies/index.js +2 -2
  53. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  54. package/dist/components/chat/entity-cards/employee-entry-card.d.ts.map +1 -1
  55. package/dist/components/chat/index.cjs +2 -2
  56. package/dist/components/chat/index.js +1 -1
  57. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  58. package/dist/components/chat/types/message.types.d.ts +8 -0
  59. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  60. package/dist/components/chat/utils/message-segment-accumulator.d.ts +1 -1
  61. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  62. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  63. package/dist/components/comment-card.d.ts +2 -18
  64. package/dist/components/comment-card.d.ts.map +1 -1
  65. package/dist/components/contact/index.cjs +3 -3
  66. package/dist/components/contact/index.js +2 -2
  67. package/dist/components/docs/index.cjs +5 -5
  68. package/dist/components/docs/index.js +4 -4
  69. package/dist/components/embeds/index.cjs +4 -6
  70. package/dist/components/embeds/index.cjs.map +1 -1
  71. package/dist/components/embeds/index.d.ts +0 -2
  72. package/dist/components/embeds/index.d.ts.map +1 -1
  73. package/dist/components/embeds/index.js +3 -5
  74. package/dist/components/faq/index.cjs +3 -3
  75. package/dist/components/faq/index.js +2 -2
  76. package/dist/components/features/array-entry-manager.d.ts.map +1 -1
  77. package/dist/components/features/clickup-tasks-manager.d.ts +1 -6
  78. package/dist/components/features/clickup-tasks-manager.d.ts.map +1 -1
  79. package/dist/components/features/index.cjs +2 -2
  80. package/dist/components/features/index.js +1 -1
  81. package/dist/components/help-center-pages/index.cjs +22 -22
  82. package/dist/components/help-center-pages/index.js +13 -13
  83. package/dist/components/index.cjs +204 -281
  84. package/dist/components/index.cjs.map +1 -1
  85. package/dist/components/index.d.ts +0 -1
  86. package/dist/components/index.d.ts.map +1 -1
  87. package/dist/components/index.js +570 -647
  88. package/dist/components/index.js.map +1 -1
  89. package/dist/components/meeting-scheduler/index.cjs +30 -30
  90. package/dist/components/meeting-scheduler/index.js +3 -3
  91. package/dist/components/navigation/index.cjs +2 -2
  92. package/dist/components/navigation/index.js +1 -1
  93. package/dist/components/onboarding-guides/index.cjs +5 -5
  94. package/dist/components/onboarding-guides/index.js +4 -4
  95. package/dist/components/related-content/index.cjs +3 -3
  96. package/dist/components/related-content/index.js +2 -2
  97. package/dist/components/shared/delivery/delivery-row.d.ts +1 -5
  98. package/dist/components/shared/delivery/delivery-row.d.ts.map +1 -1
  99. package/dist/components/shared/roadmap/roadmap-grid.d.ts +1 -10
  100. package/dist/components/shared/roadmap/roadmap-grid.d.ts.map +1 -1
  101. package/dist/components/tickets/index.cjs +6 -6
  102. package/dist/components/tickets/index.js +5 -5
  103. package/dist/components/ui/card.d.ts.map +1 -1
  104. package/dist/components/ui/index.cjs +2 -4
  105. package/dist/components/ui/index.cjs.map +1 -1
  106. package/dist/components/ui/index.d.ts +1 -1
  107. package/dist/components/ui/index.d.ts.map +1 -1
  108. package/dist/components/ui/index.js +1 -3
  109. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
  110. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -1
  111. package/dist/components/ui/table/index.d.ts +1 -1
  112. package/dist/components/ui/table/index.d.ts.map +1 -1
  113. package/dist/components/ui/table/table-row.d.ts.map +1 -1
  114. package/dist/components/ui/table/table-skeleton.d.ts +2 -15
  115. package/dist/components/ui/table/table-skeleton.d.ts.map +1 -1
  116. package/dist/components/ui/table/table.d.ts.map +1 -1
  117. package/dist/components/ui/table/types.d.ts +0 -2
  118. package/dist/components/ui/table/types.d.ts.map +1 -1
  119. package/dist/index.cjs +2 -6
  120. package/dist/index.cjs.map +1 -1
  121. package/dist/index.js +1 -5
  122. package/dist/types/employee.d.ts +2 -25
  123. package/dist/types/employee.d.ts.map +1 -1
  124. package/dist/types/index.d.ts +0 -2
  125. package/dist/types/index.d.ts.map +1 -1
  126. package/dist/types/marketing.d.ts +1 -1
  127. package/dist/types/marketing.d.ts.map +1 -1
  128. package/dist/types/profile.d.ts +1 -2
  129. package/dist/types/profile.d.ts.map +1 -1
  130. package/dist/types/team.d.ts +1 -15
  131. package/dist/types/team.d.ts.map +1 -1
  132. package/dist/types/user.d.ts +1 -3
  133. package/dist/types/user.d.ts.map +1 -1
  134. package/dist/utils/embed-url-converters.d.ts +0 -17
  135. package/dist/utils/embed-url-converters.d.ts.map +1 -1
  136. package/dist/utils/index.cjs +0 -12
  137. package/dist/utils/index.cjs.map +1 -1
  138. package/dist/utils/index.js +1 -12
  139. package/dist/utils/index.js.map +1 -1
  140. package/package.json +1 -1
  141. package/src/components/.comment-card.md +5 -4
  142. package/src/components/chat/__tests__/ticket-event.test.ts +86 -0
  143. package/src/components/chat/chat-message-enhanced.tsx +3 -1
  144. package/src/components/chat/entity-cards/employee-entry-card.tsx +0 -6
  145. package/src/components/chat/stream/chat-stream-reducer.ts +7 -1
  146. package/src/components/chat/types/message.types.ts +8 -0
  147. package/src/components/chat/utils/message-segment-accumulator.ts +10 -3
  148. package/src/components/chat/utils/process-historical-messages.ts +17 -8
  149. package/src/components/comment-card.tsx +17 -42
  150. package/src/components/embeds/index.ts +0 -2
  151. package/src/components/features/array-entry-manager.tsx +1 -2
  152. package/src/components/features/clickup-tasks-manager.tsx +1 -8
  153. package/src/components/index.ts +0 -1
  154. package/src/components/shared/delivery/delivery-row.tsx +1 -17
  155. package/src/components/shared/roadmap/roadmap-grid.tsx +1 -21
  156. package/src/components/ui/card.tsx +1 -4
  157. package/src/components/ui/index.ts +0 -1
  158. package/src/components/ui/markdown/rich/__tests__/shortcodes.test.ts +0 -44
  159. package/src/components/ui/markdown/rich/embed-overrides.tsx +0 -4
  160. package/src/components/ui/markdown/rich/shortcodes.ts +0 -12
  161. package/src/components/ui/table/index.ts +1 -1
  162. package/src/components/ui/table/table-row.tsx +2 -7
  163. package/src/components/ui/table/table-skeleton.tsx +3 -20
  164. package/src/components/ui/table/table.tsx +0 -1
  165. package/src/components/ui/table/types.ts +0 -2
  166. package/src/types/employee.ts +2 -27
  167. package/src/types/index.ts +0 -2
  168. package/src/types/marketing.ts +0 -1
  169. package/src/types/profile.ts +1 -3
  170. package/src/types/team.ts +1 -17
  171. package/src/types/user.ts +1 -4
  172. package/src/utils/embed-url-converters.ts +0 -32
  173. package/dist/chunk-5I3BSQ6D.js +0 -2
  174. package/dist/chunk-GZL5SAUI.cjs.map +0 -1
  175. package/dist/chunk-MSUSONUK.js.map +0 -1
  176. package/dist/chunk-PZH3CZTJ.cjs.map +0 -1
  177. package/dist/chunk-TA5P2M7V.cjs.map +0 -1
  178. package/dist/chunk-UGAHDPNU.cjs +0 -2
  179. package/dist/chunk-YVOMHB73.js.map +0 -1
  180. package/dist/components/comment-composer.d.ts +0 -42
  181. package/dist/components/comment-composer.d.ts.map +0 -1
  182. package/dist/components/embeds/claude-embed.d.ts +0 -31
  183. package/dist/components/embeds/claude-embed.d.ts.map +0 -1
  184. package/dist/types/department.d.ts +0 -41
  185. package/dist/types/department.d.ts.map +0 -1
  186. package/dist/types/design-doc.d.ts +0 -247
  187. package/dist/types/design-doc.d.ts.map +0 -1
  188. package/src/components/comment-composer.tsx +0 -117
  189. package/src/components/embeds/claude-embed.tsx +0 -84
  190. package/src/types/department.ts +0 -44
  191. package/src/types/design-doc.ts +0 -295
  192. package/src/utils/__tests__/claude-embed-url.test.ts +0 -42
  193. /package/dist/{chunk-GG6CAELN.js.map → chunk-3FBPPKV2.js.map} +0 -0
  194. /package/dist/{chunk-EQEMZU45.js.map → chunk-5K3F5AZX.js.map} +0 -0
  195. /package/dist/{chunk-XPLZB74T.js.map → chunk-6RILRC7X.js.map} +0 -0
  196. /package/dist/{chunk-ZUVVJ24E.js.map → chunk-7IR5LKYC.js.map} +0 -0
  197. /package/dist/{chunk-OEN3GTRL.js.map → chunk-DEYRHTLD.js.map} +0 -0
  198. /package/dist/{chunk-MIKGZY7V.js.map → chunk-GCMASPSW.js.map} +0 -0
  199. /package/dist/{chunk-ZYC4O76Y.js.map → chunk-HJ7OVIR7.js.map} +0 -0
  200. /package/dist/{chunk-XZNKPEWB.js.map → chunk-MIBKEMZY.js.map} +0 -0
  201. /package/dist/{chunk-5I3BSQ6D.js.map → chunk-MVWSLZG7.js.map} +0 -0
  202. /package/dist/{chunk-SQ53GVCG.js.map → chunk-QQFUJ6MS.js.map} +0 -0
  203. /package/dist/{chunk-XSK7ZQ3C.js.map → chunk-TXLUKQBI.js.map} +0 -0
  204. /package/dist/{chunk-LOWWLH2W.js.map → chunk-UK6SSMUR.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.565-1861.5100.1",
3
+ "version": "0.0.565",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,18 +1,18 @@
1
1
  <!-- source-hash: 2dc22952dfcc6e283cfc3b6d4d636ade -->
2
- Renders a styled comment card that adapts its header to the surface: it leads with the vendor a comment is about on a user profile, or with the commenter everywhere else. Delete is a caller-supplied rule.
2
+ Renders a styled comment card that adapts its layout based on context showing vendor information when viewing a user profile, or user information when viewing a vendor page with optional delete functionality based on auth state.
3
3
 
4
4
  ## Key Components
5
5
 
6
6
  ### `CommentCard` (default export)
7
7
 
8
- The comment tile: one card for every comment surface, with the header lead, the body length and deletability all supplied as props.
8
+ A context-aware card component for displaying user-submitted vendor comments.
9
9
 
10
10
  **Props (`CommentCardProps`):**
11
11
 
12
12
  | Prop | Type | Default | Description |
13
13
  |------|------|---------|-------------|
14
14
  | `comment` | `object` | — | Comment data including content, author, vendor, and metadata |
15
- | `leadWith` | `'vendor' \| 'commenter'` | `'commenter'` | Which header the tile leads with: the vendor the comment is about (profile page) or the person who wrote it |
15
+ | `context` | `'profile' \| 'vendor'` | `'profile'` | Controls layout and delete permission logic |
16
16
  | `onViewProduct` | `(slug: string) => void` | — | Callback when vendor button is clicked |
17
17
  | `onDeleteComment` | `(id: string) => void` | — | Callback when delete is triggered |
18
18
  | `showVendorInfo` | `boolean` | `true` | Toggles vendor info display |
@@ -20,13 +20,14 @@ The comment tile: one card for every comment surface, with the header lead, the
20
20
 
21
21
  **Internal logic:**
22
22
 
23
- - `canUserDeleteComment()` — Deletability is the CALLER's rule: an explicit `comment.canDelete` wins, and the default is "your own comment" (`currentUser.id === comment.user.id`). No per-surface branch.
23
+ - `canUserDeleteComment()` — Grants delete access to authenticated users; in `profile` context, respects the `canDelete` prop; in `vendor` context, compares `currentUser.id` against `comment.user.id`
24
24
  - `formatActivityTime()` — Delegates to `formatRelativeTime` utility for human-readable timestamps
25
25
 
26
26
  ## Usage Example
27
27
 
28
28
  ```typescript
29
29
  <CommentCard
30
+ context="vendor"
30
31
  comment={{
31
32
  id: "abc123",
32
33
  content: "Solid RMM tool for small MSPs.",
@@ -134,6 +134,21 @@ describe('chat stream reducer — ticket event', () => {
134
134
  ])
135
135
  expect(events(trailingSegments(reducer))).toHaveLength(3)
136
136
  })
137
+
138
+ it('stamps a live card with its arrival time, not the bubble timestamp', () => {
139
+ // The chunk carries no event time, so arrival IS the event time for a
140
+ // genuinely live receipt. Without the stamp the card renders the trailing
141
+ // bubble's timestamp — the turn's FIRST row, minutes stale by the time a
142
+ // resolve/reopen lands.
143
+ const reducer = createChatStreamReducer({ transport: 'nats' })
144
+ const before = Date.now()
145
+ feed(reducer, [resolvedChunk(1)])
146
+ const after = Date.now()
147
+ const [card] = events(trailingSegments(reducer))
148
+ expect(card.occurredAt).toBeInstanceOf(Date)
149
+ expect(card.occurredAt!.getTime()).toBeGreaterThanOrEqual(before)
150
+ expect(card.occurredAt!.getTime()).toBeLessThanOrEqual(after)
151
+ })
137
152
  })
138
153
 
139
154
  describe('accumulator — hydrate/catch-up overlap', () => {
@@ -262,4 +277,75 @@ describe('processHistoricalMessages — ticket event', () => {
262
277
  ])
263
278
  expect(messages.flatMap((m) => events(m.content))).toHaveLength(0)
264
279
  })
280
+
281
+ it('stamps each card with its OWN row time, not the bubble timestamp', () => {
282
+ // The reported bug: resolve/reopen rows grouped into one assistant bubble
283
+ // all rendered the bubble's timestamp — the FIRST row of the turn (the
284
+ // ticket-creation-era greeting), so every lifecycle card read the same
285
+ // stale time.
286
+ const rowAt = (id: string, createdAt: string, data: Record<string, unknown>): HistoricalMessage => ({
287
+ id,
288
+ createdAt,
289
+ owner: { type: 'ASSISTANT' },
290
+ messageData: [data as never],
291
+ })
292
+ const { messages } = processHistoricalMessages([
293
+ rowAt('m1', '2026-08-20T13:54:00Z', { type: 'TEXT', text: 'Hi! How can I help?' }),
294
+ rowAt('m2', '2026-08-20T14:10:00Z', { type: 'TICKET_EVENT', kind: 'RESOLVED', actorType: 'AI' }),
295
+ rowAt('m3', '2026-08-20T14:33:00Z', {
296
+ type: 'TICKET_EVENT',
297
+ kind: 'REOPENED',
298
+ actorType: 'CLIENT',
299
+ targetStatusKind: 'AI_ASSISTANCE',
300
+ }),
301
+ ])
302
+ expect(messages).toHaveLength(1)
303
+ // The bubble keeps the first row's time…
304
+ expect(messages[0].timestamp).toEqual(new Date('2026-08-20T13:54:00Z'))
305
+ // …but each card carries the time of its own event.
306
+ const cards = events(messages[0].content)
307
+ expect(cards.map((c) => c.occurredAt)).toEqual([
308
+ new Date('2026-08-20T14:10:00Z'),
309
+ new Date('2026-08-20T14:33:00Z'),
310
+ ])
311
+ })
312
+ })
313
+
314
+ describe('accumulator — occurredAt across the hydrate/catch-up overlap', () => {
315
+ it('keeps the hydrated row time when the seq-stamped redelivery carries none', () => {
316
+ const accumulator = new MessageSegmentAccumulator()
317
+ accumulator.addTicketEvent(
318
+ { kind: 'RESOLVED', actorId: 'fae', actorName: 'Fae', actorType: 'AI' },
319
+ undefined,
320
+ new Date('2026-08-20T14:10:00Z'),
321
+ )
322
+ // Same event redelivered live (JetStream catch-up): seq known, time not.
323
+ const segments = accumulator.addTicketEvent(
324
+ { kind: 'RESOLVED', actorId: 'fae', actorName: 'Fae', actorType: 'AI' },
325
+ 412,
326
+ )
327
+ const rendered = events(segments)
328
+ expect(rendered).toHaveLength(1)
329
+ expect(rendered[0].streamSeq).toBe(412)
330
+ expect(rendered[0].occurredAt).toEqual(new Date('2026-08-20T14:10:00Z'))
331
+ })
332
+
333
+ it('keeps the hydrated row time over a LATE redelivery arrival stamp', () => {
334
+ // Reconnect catch-up redelivers an hour-old event stamped with its (late)
335
+ // arrival time — the hydrated twin's real row time must win.
336
+ const accumulator = new MessageSegmentAccumulator()
337
+ accumulator.addTicketEvent(
338
+ { kind: 'RESOLVED', actorId: 'fae', actorName: 'Fae', actorType: 'AI' },
339
+ undefined,
340
+ new Date('2026-08-20T14:10:00Z'),
341
+ )
342
+ const segments = accumulator.addTicketEvent(
343
+ { kind: 'RESOLVED', actorId: 'fae', actorName: 'Fae', actorType: 'AI' },
344
+ 412,
345
+ new Date('2026-08-20T15:10:00Z'),
346
+ )
347
+ const rendered = events(segments)
348
+ expect(rendered).toHaveLength(1)
349
+ expect(rendered[0].occurredAt).toEqual(new Date('2026-08-20T14:10:00Z'))
350
+ })
265
351
  })
@@ -652,7 +652,9 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
652
652
  } else if (segment.type === 'ticket_escalated') {
653
653
  return <TicketEscalatedMessage key={index} data={segment.data} timestamp={timestamp} />
654
654
  } else if (segment.type === 'ticket_event') {
655
- return <TicketEventMessage key={index} data={segment.data} timestamp={timestamp} />
655
+ // The card's own event time; the bubble timestamp is the
656
+ // turn's FIRST row and lags every later lifecycle event.
657
+ return <TicketEventMessage key={index} data={segment.data} timestamp={segment.occurredAt ?? timestamp} />
656
658
  } else if (segment.type === 'error') {
657
659
  return (
658
660
  <ErrorMessageDisplay
@@ -38,12 +38,6 @@ const STATUS_BADGE_CLASS: Record<string, string> = {
38
38
  published: 'bg-ods-success-secondary text-ods-success',
39
39
  draft: 'bg-ods-warning-secondary text-ods-warning',
40
40
  archived: 'bg-ods-border text-ods-text-secondary',
41
- // Design-doc lifecycle (product-hub) — same ODS pairs, no new palette.
42
- in_review: 'bg-ods-warning-secondary text-ods-warning',
43
- approved: 'bg-ods-success-secondary text-ods-success',
44
- building: 'bg-ods-success-secondary text-ods-success',
45
- shipped: 'bg-ods-success-secondary text-ods-success',
46
- abandoned: 'bg-ods-border text-ods-text-secondary',
47
41
  }
48
42
 
49
43
  /** Shared badge treatment so an entity's own badges (discipline, level) sit
@@ -1378,7 +1378,13 @@ export function createChatStreamReducer(
1378
1378
  reason: event.reason,
1379
1379
  targetStatusKind: event.targetStatusKind,
1380
1380
  }
1381
- const segments = accumulator.addTicketEvent(data, seq)
1381
+ // The chunk carries no event time, so stamp arrival — for a genuinely
1382
+ // live event that IS the event time. Without it the card falls back to
1383
+ // the enclosing bubble's timestamp: the turn's FIRST row, minutes
1384
+ // stale by the time a resolve/reopen lands. A catch-up redelivery of
1385
+ // an already-hydrated event does NOT regress to arrival time —
1386
+ // `addTicketEvent` keeps the first-known `occurredAt` on upsert.
1387
+ const segments = accumulator.addTicketEvent(data, seq, new Date())
1382
1388
  applyAccumulated(before, segments)
1383
1389
  // The card alone is not enough for a host that also tracks the
1384
1390
  // ticket's state: a RESOLVED/REOPENED arriving live must move the
@@ -359,6 +359,14 @@ export type TicketEventSegment = {
359
359
  type: 'ticket_event'
360
360
  data: TicketEventData
361
361
  streamSeq?: number
362
+ /** When THIS event happened (history: the persisted row's `createdAt`;
363
+ * live: chunk arrival). Kept beside `streamSeq`, not in `data`: the
364
+ * upsert's payload-equality fallback must keep matching a redelivered
365
+ * chunk to its hydrated twin, and their times come from different clocks.
366
+ * Without it the card inherits the surrounding assistant bubble's
367
+ * timestamp — the FIRST row of the whole turn, so every lifecycle card
368
+ * reads the same stale time. */
369
+ occurredAt?: Date
362
370
  }
363
371
 
364
372
  export type ErrorSegment = {
@@ -550,11 +550,12 @@ export class MessageSegmentAccumulator {
550
550
  * by the same actor is payload-identical to the first resolve, and matching
551
551
  * the old card meant the final one never rendered.
552
552
  */
553
- addTicketEvent(data: TicketEventData, streamSeq?: number): MessageSegment[] {
553
+ addTicketEvent(data: TicketEventData, streamSeq?: number, occurredAt?: Date): MessageSegment[] {
554
554
  const segment: TicketEventSegment = {
555
555
  type: 'ticket_event',
556
556
  data,
557
557
  ...(streamSeq !== undefined ? { streamSeq } : {}),
558
+ ...(occurredAt !== undefined ? { occurredAt } : {}),
558
559
  }
559
560
  let existingIndex =
560
561
  streamSeq !== undefined
@@ -581,7 +582,13 @@ export class MessageSegmentAccumulator {
581
582
  }
582
583
  }
583
584
  if (existingIndex !== -1) {
584
- this.segments[existingIndex] = segment
585
+ // The FIRST-known time wins: the hydrated twin carries the row's real
586
+ // `createdAt`, while a catch-up redelivery only knows its (late) arrival
587
+ // time — replacing wholesale would regress the card to arrival or, with
588
+ // neither side stamped, to the bubble timestamp.
589
+ const existing = this.segments[existingIndex] as TicketEventSegment
590
+ const occurredAt = existing.occurredAt ?? segment.occurredAt
591
+ this.segments[existingIndex] = occurredAt !== undefined ? { ...segment, occurredAt } : segment
585
592
  return this.getSegments()
586
593
  }
587
594
  this.segments.push(segment)
@@ -734,7 +741,7 @@ export class MessageSegmentAccumulator {
734
741
  this.addTicketEscalated(segment.data)
735
742
  break
736
743
  case 'ticket_event':
737
- this.addTicketEvent(segment.data, segment.streamSeq)
744
+ this.addTicketEvent(segment.data, segment.streamSeq, segment.occurredAt)
738
745
  break
739
746
  case 'error':
740
747
  this.addError(segment.title, segment.details)
@@ -330,6 +330,7 @@ function applyHistoryEvent(
330
330
  options: MessageProcessingOptions,
331
331
  escalatedApprovals?: EscalatedApprovals,
332
332
  offerResolutions?: OfferResolutions,
333
+ rowCreatedAt?: Date,
333
334
  ): void {
334
335
  // batchApprovalsEnabled is owned by the consumer (oss-tenant chat client /
335
336
  // openframe-frontend tickets). Defaults to ON so consumers that haven't
@@ -362,14 +363,21 @@ function applyHistoryEvent(
362
363
  // same event matches this segment by payload equality (see
363
364
  // `addTicketEvent`).
364
365
  case 'ticket-event':
365
- accumulator.addTicketEvent({
366
- kind: event.kind,
367
- actorId: event.actorId,
368
- actorName: event.actorName,
369
- actorType: event.actorType,
370
- reason: event.reason,
371
- targetStatusKind: event.targetStatusKind,
372
- })
366
+ // `rowCreatedAt` is the persisted row's own time — without it the card
367
+ // renders the enclosing assistant bubble's timestamp, i.e. the FIRST
368
+ // row of the turn, and every lifecycle card reads the same stale time.
369
+ accumulator.addTicketEvent(
370
+ {
371
+ kind: event.kind,
372
+ actorId: event.actorId,
373
+ actorName: event.actorName,
374
+ actorType: event.actorType,
375
+ reason: event.reason,
376
+ targetStatusKind: event.targetStatusKind,
377
+ },
378
+ undefined,
379
+ rowCreatedAt,
380
+ )
373
381
  break
374
382
 
375
383
  case 'escalation-offer-resolved':
@@ -738,6 +746,7 @@ export function processHistoricalMessages(
738
746
  { displayApprovalTypes, batchApprovalsEnabled, escalationOfferStates },
739
747
  escalatedApprovals,
740
748
  offerResolutions,
749
+ new Date(msg.createdAt),
741
750
  )
742
751
  })
743
752
 
@@ -37,23 +37,7 @@ interface CommentCardProps {
37
37
  onDeleteComment?: (commentId: string) => void
38
38
  showVendorInfo?: boolean
39
39
  compact?: boolean
40
- /**
41
- * Which HEADER this tile leads with — the only thing that ever varied by
42
- * surface. Two states, because there are two headers: the profile page shows
43
- * a person's comments, so it leads with the VENDOR each one is about;
44
- * everywhere else the vendor is the page and the commenter is the news.
45
- * Every BEHAVIOUR is a prop, so a new surface never needs a new member here.
46
- */
47
- leadWith?: 'vendor' | 'commenter'
48
- /**
49
- * Read the body in FULL (thread reading) instead of the fixed-height,
50
- * three-line list tile. Defaults to the list tile.
51
- */
52
- fullBody?: boolean
53
- /** Rendered under the body — badges + per-surface actions (design-doc threads). */
54
- footer?: React.ReactNode
55
- /** Rendered after the card — nested replies + reply composer (design-doc threads). */
56
- children?: React.ReactNode
40
+ context: 'profile' | 'vendor'
57
41
  }
58
42
 
59
43
  export function CommentCard({
@@ -62,10 +46,7 @@ export function CommentCard({
62
46
  onDeleteComment,
63
47
  showVendorInfo = true,
64
48
  compact = false,
65
- leadWith = 'commenter',
66
- fullBody = false,
67
- footer,
68
- children
49
+ context = 'profile'
69
50
  }: CommentCardProps) {
70
51
  const { user: currentUser } = useAuth()
71
52
 
@@ -75,28 +56,32 @@ export function CommentCard({
75
56
  }
76
57
 
77
58
  // Check if current user can delete this comment
78
- /**
79
- * Deletability is the CALLER's rule, never a per-surface branch in here: an
80
- * explicit `comment.canDelete` always wins (design docs compute own-or-DRI
81
- * with their gate module and hand it in), and the default is the only rule
82
- * this component can honestly know by itself — your own comment.
83
- */
84
59
  const canUserDeleteComment = () => {
60
+ // If not authenticated, can't delete
85
61
  if (!currentUser) return false
86
- if (typeof comment.canDelete === 'boolean') return comment.canDelete
87
- return !!comment.user && currentUser.id === comment.user.id
62
+
63
+ // For profile context, use the existing canDelete prop (since it's the user's own comments)
64
+ if (context === 'profile') {
65
+ return comment.canDelete !== false
66
+ }
67
+
68
+ // For vendor context, check if current user is the comment creator
69
+ if (context === 'vendor' && comment.user) {
70
+ return currentUser.id === comment.user.id
71
+ }
72
+
73
+ return false
88
74
  }
89
75
 
90
76
  const showDeleteButton = canUserDeleteComment()
91
77
 
92
78
  return (
93
- <>
94
79
  <div className="bg-ods-card border border-ods-border rounded-lg p-4 hover:border-ods-accent transition-all group overflow-hidden w-full max-w-full box-border" style={{ maxWidth: '100%', wordBreak: 'break-word' }}>
95
80
  {/* Comment Header */}
96
81
  <div className="flex flex-col gap-3 mb-3 min-[420px]:flex-row min-[420px]:items-center min-[420px]:justify-between min-[420px]:gap-2 w-full">
97
82
  {/* Row 1: Info + Timestamp */}
98
83
  <div className="flex items-center gap-2 min-w-0 flex-1">
99
- {leadWith === 'vendor' && comment.vendor ? (
84
+ {context === 'profile' && comment.vendor ? (
100
85
  <>
101
86
  {/* Vendor Button - Icon + Name */}
102
87
  <VendorDisplayButton
@@ -109,7 +94,7 @@ export function CommentCard({
109
94
  {formatActivityTime(comment.createdAt)}
110
95
  </span>
111
96
  </>
112
- ) : comment.user ? (
97
+ ) : context === 'vendor' && comment.user ? (
113
98
  <UserSummary
114
99
  name={comment.user.name}
115
100
  email=""
@@ -167,12 +152,6 @@ export function CommentCard({
167
152
  </h4>
168
153
  </div>
169
154
  )}
170
- {fullBody ? (
171
- // Thread bodies are read in full — no fixed height, no clamp.
172
- <p className="text-h4 text-ods-text-primary whitespace-pre-wrap" style={{ wordBreak: 'break-word', overflowWrap: 'break-word', maxWidth: '100%' }}>
173
- {comment.content}
174
- </p>
175
- ) : (
176
155
  <div className={compact ? "h-[60px] flex items-center" : "h-[72px] flex items-center"}>
177
156
  <p className="text-h4 text-ods-text-primary line-clamp-3" style={{
178
157
  wordBreak: 'break-word',
@@ -187,11 +166,7 @@ export function CommentCard({
187
166
  {comment.content}
188
167
  </p>
189
168
  </div>
190
- )}
191
- {footer ? <div className="pt-2 flex flex-wrap items-center gap-2">{footer}</div> : null}
192
169
  </div>
193
170
  </div>
194
- {children}
195
- </>
196
171
  )
197
172
  }
@@ -11,8 +11,6 @@ export type { GoogleSheetsViewerProps } from './google-sheets-viewer'
11
11
 
12
12
  export { FigmaEmbed } from './figma-embed'
13
13
  export type { FigmaEmbedProps } from './figma-embed'
14
- export { ClaudeEmbed } from './claude-embed'
15
- export type { ClaudeEmbedProps, ClaudeEmbedKind } from './claude-embed'
16
14
 
17
15
  export { OGLinkPreview, OGLinkErrorBoundary } from './og-link-preview'
18
16
  export type {
@@ -159,8 +159,7 @@ export function ArrayEntryManager<T extends { [key: string]: any }>({
159
159
  </div>
160
160
  )}
161
161
 
162
- {/* min-w-0: a wide renderLabel (e.g. a DeliveryRow) must truncate, not push the row past its host. */}
163
- <div className="flex-1 min-w-0 space-y-2">
162
+ <div className="flex-1 space-y-2">
164
163
  {renderLabel && renderLabel(item, index)}
165
164
  <Input
166
165
  placeholder={placeholder}
@@ -14,19 +14,13 @@ interface ClickUpTasksManagerProps {
14
14
  onChange: (tasks: ClickUpTaskLink[]) => void;
15
15
  title?: ReactNode; // Support string or ReactNode for badge integration
16
16
  className?: string;
17
- /**
18
- * Per-row label renderer (passthrough to ArrayEntryManager) — lets a host
19
- * hydrate the bare task id with the mirror's title/status row.
20
- */
21
- renderLabel?: (item: ClickUpTaskLink, index: number) => ReactNode;
22
17
  }
23
18
 
24
19
  export function ClickUpTasksManager({
25
20
  tasks,
26
21
  onChange,
27
22
  title = 'ClickUp Roadmap Tasks', // Default title
28
- className = '',
29
- renderLabel
23
+ className = ''
30
24
  }: ClickUpTasksManagerProps) {
31
25
  return (
32
26
  <ArrayEntryManager
@@ -39,7 +33,6 @@ export function ClickUpTasksManager({
39
33
  addButtonText="Add Task"
40
34
  icon={<Lightbulb className="w-5 h-5 text-ods-text-secondary" />}
41
35
  className={className}
42
- renderLabel={renderLabel}
43
36
  />
44
37
  );
45
38
  }
@@ -16,7 +16,6 @@ export * from './hover-card'
16
16
  export * from './categories-cart'
17
17
  export * from './category-card'
18
18
  export * from './comment-card'
19
- export * from './comment-composer'
20
19
  export * from './content-loading-container'
21
20
  export * from './dynamic-skeleton'
22
21
  export * from './empty-state'
@@ -68,17 +68,12 @@ export interface DeliveryRowProps {
68
68
  * paired with `scroll-mt-24` on the outer element so the row lands
69
69
  * BELOW the sticky chrome after the scroll. */
70
70
  id?: string
71
- /** `'_blank'` renders a plain external anchor (new tab, `rel` hardened) —
72
- * for rows whose href leaves the app (e.g. the raw ClickUp task URL on the
73
- * design-doc surfaces). Internal deep-links omit it and keep the Link shim. */
74
- target?: '_blank'
75
71
  className?: string
76
72
  }
77
73
 
78
74
  export function DeliveryRow({
79
75
  item,
80
76
  href,
81
- target,
82
77
  caption,
83
78
  id,
84
79
  className,
@@ -105,10 +100,7 @@ export function DeliveryRow({
105
100
  </h3>
106
101
  </div>
107
102
  <div className="min-h-[20px] flex items-center">
108
- {/* Relative time is wall-clock-derived: on an SSR host the server and
109
- client render different strings a moment apart — text-only, so
110
- suppress instead of forcing a client-only render. */}
111
- <p className="text-h5 text-ods-text-secondary uppercase tracking-[-0.28px] truncate" suppressHydrationWarning>
103
+ <p className="text-h5 text-ods-text-secondary uppercase tracking-[-0.28px] truncate">
112
104
  {subtitle}
113
105
  </p>
114
106
  </div>
@@ -163,14 +155,6 @@ export function DeliveryRow({
163
155
  className,
164
156
  )
165
157
 
166
- if (href && target === '_blank') {
167
- return (
168
- <a href={href} id={id} className={baseClass} target="_blank" rel="noopener noreferrer">
169
- {inner}
170
- </a>
171
- )
172
- }
173
-
174
158
  if (href) {
175
159
  // `Link` is the env-aware embed-shim — delegates to `next/link` on
176
160
  // a Next.js host (soft RSC nav, back-button restores the previous
@@ -100,15 +100,6 @@ export interface RoadmapGridProps {
100
100
  /** Show the desktop left margin (~120px) that aligns the grid with the page
101
101
  * hero. Default `true`. Related-content rails pass `false`. */
102
102
  showLeftMargin?: boolean;
103
- /**
104
- * Columns at `md` and up. Default `2` (the roadmap page).
105
- *
106
- * A card in this grid needs real width — its title, status badge, target
107
- * version and vote row sit on one line each. Two columns inside a NARROW
108
- * host (a modal rail, a sidebar) halve an already-small container and crush
109
- * every one of them, so such hosts pass `1`.
110
- */
111
- columns?: 1 | 2;
112
103
  /** URL builder for the per-task refresh call after a successful vote. */
113
104
  buildRefreshUrl?: (taskId: string) => string;
114
105
  /** Voting hook options (vote endpoint + storage key). */
@@ -131,26 +122,18 @@ export interface RoadmapGridProps {
131
122
  function RoadmapGridSingle({
132
123
  items,
133
124
  showLeftMargin,
134
- columns = 2,
135
125
  getVote,
136
126
  onVote,
137
127
  votingTasks,
138
128
  }: {
139
129
  items: RoadmapItem[];
140
130
  showLeftMargin: boolean;
141
- columns?: 1 | 2;
142
131
  getVote: (taskId: string) => 'up' | 'down' | null;
143
132
  onVote: (taskId: string, voteType: 'up' | 'down') => void;
144
133
  votingTasks: Set<string>;
145
134
  }) {
146
135
  return (
147
- // LITERAL class strings: an interpolated `md:grid-cols-${n}` is invisible
148
- // to Tailwind's scanner and is never generated.
149
- <div
150
- className={`grid grid-cols-1 gap-6 ${columns === 2 ? 'md:grid-cols-2' : ''} ${
151
- showLeftMargin ? 'md:ml-[120px]' : ''
152
- }`}
153
- >
136
+ <div className={`grid grid-cols-1 md:grid-cols-2 gap-6 ${showLeftMargin ? 'md:ml-[120px]' : ''}`}>
154
137
  {items.map((item) => (
155
138
  // DOM id + sticky-header scroll offset live ON RoadmapCard's own
156
139
  // outer element (no wrapper div). Anchor mirrors
@@ -174,7 +157,6 @@ export function RoadmapGrid({
174
157
  items,
175
158
  onItemUpdate,
176
159
  showLeftMargin = true,
177
- columns = 2,
178
160
  buildRefreshUrl = DEFAULT_BUILD_REFRESH_URL,
179
161
  votingOptions,
180
162
  groupByQuarter = false,
@@ -273,7 +255,6 @@ export function RoadmapGrid({
273
255
  <RoadmapGridSingle
274
256
  items={items}
275
257
  showLeftMargin={showLeftMargin}
276
- columns={columns}
277
258
  getVote={getVote}
278
259
  onVote={handleVote}
279
260
  votingTasks={votingTasks}
@@ -324,7 +305,6 @@ export function RoadmapGrid({
324
305
  <RoadmapGridSingle
325
306
  items={itemsByQuarter[quarter]}
326
307
  showLeftMargin={showLeftMargin}
327
- columns={columns}
328
308
  getVote={getVote}
329
309
  onVote={handleVote}
330
310
  votingTasks={votingTasks}
@@ -11,10 +11,7 @@ const Card = React.forwardRef<
11
11
  <div
12
12
  ref={ref}
13
13
  className={cn(
14
- // `border` with NO color token resolves to `currentColor` — on a dark card
15
- // with light text that paints a WHITE outline. Every ODS surface borders
16
- // on `--color-border`; say so.
17
- "rounded-lg border border-ods-border bg-card text-card-foreground shadow-sm",
14
+ "rounded-lg border bg-card text-card-foreground shadow-sm",
18
15
  className
19
16
  )}
20
17
  {...props}
@@ -122,7 +122,6 @@ export * from './content-loader'
122
122
 
123
123
  // Table components
124
124
  export {
125
- COMPACT_ROW_MIN_HEIGHT_PX,
126
125
  Table, TableCardSkeleton, TableCell,
127
126
  TableDescriptionCell, TableEmptyState, TableHeader,
128
127
  TableRow, TableTimestampCell
@@ -56,47 +56,3 @@ describe('processShortcodes script stripping', () => {
56
56
  expect(output).toContain('<script>');
57
57
  });
58
58
  });
59
-
60
- /**
61
- * Design-doc embeds (R11): the spec's `{{figma:FILE_KEY[:NODE_ID]}}` grammar is
62
- * rewritten onto the canonical `{{figma:URL}}` rule, and Claude artifacts get
63
- * the SAME treatment — a shortcode → a block the renderer maps to `ClaudeEmbed`.
64
- * Both are markdown blocks, so they render identically in a spec body, a
65
- * comment, or a links rail, and no surface hand-assembles its own card.
66
- */
67
- describe('processShortcodes design-doc embeds', () => {
68
- it('rewrites {{figma:KEY:NODE}} to the canonical design URL and renders the figma embed', () => {
69
- const output = processShortcodes('{{figma:aB3xK9:12-405}}');
70
- expect(output).toContain('class="figma-embed"');
71
- expect(output).toContain('data-figma-url="https://www.figma.com/design/aB3xK9?node-id=12-405"');
72
- });
73
-
74
- it('accepts a colon-separated node id and a bare key', () => {
75
- expect(processShortcodes('{{figma:aB3xK9:12:405}}')).toContain('node-id=12-405');
76
- expect(processShortcodes('{{figma:aB3xK9}}')).toContain('data-figma-url="https://www.figma.com/design/aB3xK9"');
77
- });
78
-
79
- it('leaves the existing {{figma:URL}} grammar untouched', () => {
80
- const output = processShortcodes('{{figma:https://www.figma.com/proto/aB3xK9/Flows?node-id=1-2}}');
81
- expect(output).toContain('data-figma-url="https://www.figma.com/proto/aB3xK9/Flows?node-id=1-2"');
82
- });
83
-
84
- it('renders a Claude artifact / Claude Design link as an embed block, like figma', () => {
85
- const artifact = processShortcodes('{{claude-artifact:https://claude.ai/public/artifacts/abc-123}}');
86
- expect(artifact).toContain('class="claude-embed"');
87
- expect(artifact).toContain('data-url="https://claude.ai/public/artifacts/abc-123"');
88
- expect(artifact).toContain('data-kind="artifact"');
89
-
90
- const design = processShortcodes('{{claude-design:https://claude.ai/design/xyz}}');
91
- expect(design).toContain('data-kind="design"');
92
- });
93
-
94
- it('escapes the url attribute so a crafted link cannot break out of the block', () => {
95
- const output = processShortcodes('{{claude-artifact:https://claude.ai/a"><script>alert(1)</script>}}');
96
- expect(output).not.toContain('<script>');
97
- expect(output).toContain('&quot;');
98
- });
99
-
100
-
101
-
102
- });