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

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 (189) hide show
  1. package/dist/{chunk-PKCZL56Y.cjs → chunk-4H7L4OSR.cjs} +4 -4
  2. package/dist/{chunk-PKCZL56Y.cjs.map → chunk-4H7L4OSR.cjs.map} +1 -1
  3. package/dist/chunk-5I3BSQ6D.js +2 -0
  4. package/dist/{chunk-6OPEULTD.cjs → chunk-BKDSNVGH.cjs} +9 -9
  5. package/dist/{chunk-6OPEULTD.cjs.map → chunk-BKDSNVGH.cjs.map} +1 -1
  6. package/dist/{chunk-OYFXHET4.cjs → chunk-BWMVI2KD.cjs} +7 -7
  7. package/dist/{chunk-OYFXHET4.cjs.map → chunk-BWMVI2KD.cjs.map} +1 -1
  8. package/dist/{chunk-YFLSOYX4.cjs → chunk-DLSDYLY3.cjs} +11 -11
  9. package/dist/{chunk-YFLSOYX4.cjs.map → chunk-DLSDYLY3.cjs.map} +1 -1
  10. package/dist/{chunk-NQ6UF4LP.js → chunk-EQEMZU45.js} +4 -4
  11. package/dist/{chunk-FG4BSJ4A.cjs → chunk-GG2HJQW3.cjs} +26 -26
  12. package/dist/{chunk-FG4BSJ4A.cjs.map → chunk-GG2HJQW3.cjs.map} +1 -1
  13. package/dist/{chunk-O3SEC45L.js → chunk-GG6CAELN.js} +4 -4
  14. package/dist/{chunk-J7R3VZL5.cjs → chunk-GZL5SAUI.cjs} +19 -15
  15. package/dist/chunk-GZL5SAUI.cjs.map +1 -0
  16. package/dist/{chunk-ASULU3T2.js → chunk-H4RCJ4IB.js} +7 -3
  17. package/dist/{chunk-ASULU3T2.js.map → chunk-H4RCJ4IB.js.map} +1 -1
  18. package/dist/{chunk-U5EVZLL5.cjs → chunk-KHEOXHF4.cjs} +5 -5
  19. package/dist/{chunk-U5EVZLL5.cjs.map → chunk-KHEOXHF4.cjs.map} +1 -1
  20. package/dist/{chunk-Z4VU6MJ4.js → chunk-LOWWLH2W.js} +2 -2
  21. package/dist/{chunk-NUKQWLOG.js → chunk-MIKGZY7V.js} +5 -5
  22. package/dist/{chunk-BK7V6CH5.js → chunk-MSUSONUK.js} +3297 -3209
  23. package/dist/chunk-MSUSONUK.js.map +1 -0
  24. package/dist/{chunk-PT4GS4CE.cjs → chunk-NAYXROCV.cjs} +3 -3
  25. package/dist/{chunk-PT4GS4CE.cjs.map → chunk-NAYXROCV.cjs.map} +1 -1
  26. package/dist/{chunk-G2VDS3M5.js → chunk-OEN3GTRL.js} +2 -2
  27. package/dist/{chunk-XFI5PCER.cjs → chunk-OTDVQBLI.cjs} +31 -31
  28. package/dist/{chunk-XFI5PCER.cjs.map → chunk-OTDVQBLI.cjs.map} +1 -1
  29. package/dist/{chunk-WGDZW237.cjs → chunk-PBH2BQYR.cjs} +86 -86
  30. package/dist/{chunk-WGDZW237.cjs.map → chunk-PBH2BQYR.cjs.map} +1 -1
  31. package/dist/{chunk-E6PW7GRQ.cjs → chunk-PZH3CZTJ.cjs} +89 -75
  32. package/dist/chunk-PZH3CZTJ.cjs.map +1 -0
  33. package/dist/{chunk-GEC2NHQH.cjs → chunk-QWVON3LG.cjs} +20 -20
  34. package/dist/{chunk-GEC2NHQH.cjs.map → chunk-QWVON3LG.cjs.map} +1 -1
  35. package/dist/{chunk-X4JKXYY5.js → chunk-SQ53GVCG.js} +2 -2
  36. package/dist/{chunk-AZPUAPXW.cjs → chunk-TA5P2M7V.cjs} +796 -708
  37. package/dist/chunk-TA5P2M7V.cjs.map +1 -0
  38. package/dist/chunk-UGAHDPNU.cjs +2 -0
  39. package/dist/{chunk-ZTM35Q76.cjs.map → chunk-UGAHDPNU.cjs.map} +1 -1
  40. package/dist/{chunk-T3P56JKN.js → chunk-XPLZB74T.js} +2 -2
  41. package/dist/{chunk-CQNK6RS6.js → chunk-XSK7ZQ3C.js} +2 -2
  42. package/dist/{chunk-TDYF6Q2D.js → chunk-XZNKPEWB.js} +2 -2
  43. package/dist/{chunk-VVDLVCPW.cjs → chunk-Y6IGG6RZ.cjs} +39 -39
  44. package/dist/{chunk-VVDLVCPW.cjs.map → chunk-Y6IGG6RZ.cjs.map} +1 -1
  45. package/dist/{chunk-7D62XVHH.js → chunk-YVOMHB73.js} +35 -21
  46. package/dist/chunk-YVOMHB73.js.map +1 -0
  47. package/dist/{chunk-CP7TMQBH.js → chunk-ZUVVJ24E.js} +2 -2
  48. package/dist/{chunk-DPMOEYEM.js → chunk-ZYC4O76Y.js} +2 -2
  49. package/dist/components/case-studies/index.cjs +8 -8
  50. package/dist/components/case-studies/index.js +2 -2
  51. package/dist/components/chat/entity-cards/employee-entry-card.d.ts.map +1 -1
  52. package/dist/components/chat/index.cjs +2 -2
  53. package/dist/components/chat/index.js +1 -1
  54. package/dist/components/comment-card.d.ts +18 -2
  55. package/dist/components/comment-card.d.ts.map +1 -1
  56. package/dist/components/comment-composer.d.ts +42 -0
  57. package/dist/components/comment-composer.d.ts.map +1 -0
  58. package/dist/components/contact/index.cjs +3 -3
  59. package/dist/components/contact/index.js +2 -2
  60. package/dist/components/docs/index.cjs +5 -5
  61. package/dist/components/docs/index.js +4 -4
  62. package/dist/components/embeds/claude-embed.d.ts +31 -0
  63. package/dist/components/embeds/claude-embed.d.ts.map +1 -0
  64. package/dist/components/embeds/index.cjs +6 -4
  65. package/dist/components/embeds/index.cjs.map +1 -1
  66. package/dist/components/embeds/index.d.ts +2 -0
  67. package/dist/components/embeds/index.d.ts.map +1 -1
  68. package/dist/components/embeds/index.js +5 -3
  69. package/dist/components/faq/index.cjs +3 -3
  70. package/dist/components/faq/index.js +2 -2
  71. package/dist/components/features/array-entry-manager.d.ts.map +1 -1
  72. package/dist/components/features/clickup-tasks-manager.d.ts +6 -1
  73. package/dist/components/features/clickup-tasks-manager.d.ts.map +1 -1
  74. package/dist/components/features/index.cjs +2 -2
  75. package/dist/components/features/index.js +1 -1
  76. package/dist/components/help-center-pages/index.cjs +22 -22
  77. package/dist/components/help-center-pages/index.js +13 -13
  78. package/dist/components/index.cjs +281 -204
  79. package/dist/components/index.cjs.map +1 -1
  80. package/dist/components/index.d.ts +1 -0
  81. package/dist/components/index.d.ts.map +1 -1
  82. package/dist/components/index.js +647 -570
  83. package/dist/components/index.js.map +1 -1
  84. package/dist/components/meeting-scheduler/index.cjs +30 -30
  85. package/dist/components/meeting-scheduler/index.js +3 -3
  86. package/dist/components/navigation/index.cjs +2 -2
  87. package/dist/components/navigation/index.js +1 -1
  88. package/dist/components/onboarding-guides/index.cjs +5 -5
  89. package/dist/components/onboarding-guides/index.js +4 -4
  90. package/dist/components/related-content/index.cjs +3 -3
  91. package/dist/components/related-content/index.js +2 -2
  92. package/dist/components/shared/delivery/delivery-row.d.ts +5 -1
  93. package/dist/components/shared/delivery/delivery-row.d.ts.map +1 -1
  94. package/dist/components/shared/roadmap/roadmap-grid.d.ts +10 -1
  95. package/dist/components/shared/roadmap/roadmap-grid.d.ts.map +1 -1
  96. package/dist/components/tickets/index.cjs +6 -6
  97. package/dist/components/tickets/index.js +5 -5
  98. package/dist/components/ui/card.d.ts.map +1 -1
  99. package/dist/components/ui/index.cjs +4 -2
  100. package/dist/components/ui/index.cjs.map +1 -1
  101. package/dist/components/ui/index.d.ts +1 -1
  102. package/dist/components/ui/index.d.ts.map +1 -1
  103. package/dist/components/ui/index.js +3 -1
  104. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
  105. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -1
  106. package/dist/components/ui/table/index.d.ts +1 -1
  107. package/dist/components/ui/table/index.d.ts.map +1 -1
  108. package/dist/components/ui/table/table-row.d.ts.map +1 -1
  109. package/dist/components/ui/table/table-skeleton.d.ts +15 -2
  110. package/dist/components/ui/table/table-skeleton.d.ts.map +1 -1
  111. package/dist/components/ui/table/table.d.ts.map +1 -1
  112. package/dist/components/ui/table/types.d.ts +2 -0
  113. package/dist/components/ui/table/types.d.ts.map +1 -1
  114. package/dist/index.cjs +6 -2
  115. package/dist/index.cjs.map +1 -1
  116. package/dist/index.js +5 -1
  117. package/dist/types/department.d.ts +41 -0
  118. package/dist/types/department.d.ts.map +1 -0
  119. package/dist/types/design-doc.d.ts +247 -0
  120. package/dist/types/design-doc.d.ts.map +1 -0
  121. package/dist/types/employee.d.ts +25 -2
  122. package/dist/types/employee.d.ts.map +1 -1
  123. package/dist/types/index.d.ts +2 -0
  124. package/dist/types/index.d.ts.map +1 -1
  125. package/dist/types/marketing.d.ts +1 -1
  126. package/dist/types/marketing.d.ts.map +1 -1
  127. package/dist/types/profile.d.ts +2 -1
  128. package/dist/types/profile.d.ts.map +1 -1
  129. package/dist/types/team.d.ts +15 -1
  130. package/dist/types/team.d.ts.map +1 -1
  131. package/dist/types/user.d.ts +3 -1
  132. package/dist/types/user.d.ts.map +1 -1
  133. package/dist/utils/embed-url-converters.d.ts +17 -0
  134. package/dist/utils/embed-url-converters.d.ts.map +1 -1
  135. package/dist/utils/index.cjs +12 -0
  136. package/dist/utils/index.cjs.map +1 -1
  137. package/dist/utils/index.js +12 -1
  138. package/dist/utils/index.js.map +1 -1
  139. package/package.json +1 -1
  140. package/src/components/.comment-card.md +4 -5
  141. package/src/components/chat/entity-cards/employee-entry-card.tsx +6 -0
  142. package/src/components/comment-card.tsx +42 -17
  143. package/src/components/comment-composer.tsx +117 -0
  144. package/src/components/embeds/claude-embed.tsx +84 -0
  145. package/src/components/embeds/index.ts +2 -0
  146. package/src/components/features/array-entry-manager.tsx +2 -1
  147. package/src/components/features/clickup-tasks-manager.tsx +8 -1
  148. package/src/components/index.ts +1 -0
  149. package/src/components/shared/delivery/delivery-row.tsx +17 -1
  150. package/src/components/shared/roadmap/roadmap-grid.tsx +21 -1
  151. package/src/components/ui/card.tsx +4 -1
  152. package/src/components/ui/index.ts +1 -0
  153. package/src/components/ui/markdown/rich/__tests__/shortcodes.test.ts +44 -0
  154. package/src/components/ui/markdown/rich/embed-overrides.tsx +4 -0
  155. package/src/components/ui/markdown/rich/shortcodes.ts +12 -0
  156. package/src/components/ui/table/index.ts +1 -1
  157. package/src/components/ui/table/table-row.tsx +7 -2
  158. package/src/components/ui/table/table-skeleton.tsx +20 -3
  159. package/src/components/ui/table/table.tsx +1 -0
  160. package/src/components/ui/table/types.ts +2 -0
  161. package/src/types/department.ts +44 -0
  162. package/src/types/design-doc.ts +295 -0
  163. package/src/types/employee.ts +27 -2
  164. package/src/types/index.ts +2 -0
  165. package/src/types/marketing.ts +1 -0
  166. package/src/types/profile.ts +3 -1
  167. package/src/types/team.ts +17 -1
  168. package/src/types/user.ts +4 -1
  169. package/src/utils/__tests__/claude-embed-url.test.ts +42 -0
  170. package/src/utils/embed-url-converters.ts +32 -0
  171. package/dist/chunk-7D62XVHH.js.map +0 -1
  172. package/dist/chunk-AZPUAPXW.cjs.map +0 -1
  173. package/dist/chunk-BK7V6CH5.js.map +0 -1
  174. package/dist/chunk-E6PW7GRQ.cjs.map +0 -1
  175. package/dist/chunk-J7R3VZL5.cjs.map +0 -1
  176. package/dist/chunk-MVWSLZG7.js +0 -2
  177. package/dist/chunk-ZTM35Q76.cjs +0 -2
  178. /package/dist/{chunk-MVWSLZG7.js.map → chunk-5I3BSQ6D.js.map} +0 -0
  179. /package/dist/{chunk-NQ6UF4LP.js.map → chunk-EQEMZU45.js.map} +0 -0
  180. /package/dist/{chunk-O3SEC45L.js.map → chunk-GG6CAELN.js.map} +0 -0
  181. /package/dist/{chunk-Z4VU6MJ4.js.map → chunk-LOWWLH2W.js.map} +0 -0
  182. /package/dist/{chunk-NUKQWLOG.js.map → chunk-MIKGZY7V.js.map} +0 -0
  183. /package/dist/{chunk-G2VDS3M5.js.map → chunk-OEN3GTRL.js.map} +0 -0
  184. /package/dist/{chunk-X4JKXYY5.js.map → chunk-SQ53GVCG.js.map} +0 -0
  185. /package/dist/{chunk-T3P56JKN.js.map → chunk-XPLZB74T.js.map} +0 -0
  186. /package/dist/{chunk-CQNK6RS6.js.map → chunk-XSK7ZQ3C.js.map} +0 -0
  187. /package/dist/{chunk-TDYF6Q2D.js.map → chunk-XZNKPEWB.js.map} +0 -0
  188. /package/dist/{chunk-CP7TMQBH.js.map → chunk-ZUVVJ24E.js.map} +0 -0
  189. /package/dist/{chunk-DPMOEYEM.js.map → chunk-ZYC4O76Y.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.564",
3
+ "version": "0.0.565-1861.5100.1",
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 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.
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.
3
3
 
4
4
  ## Key Components
5
5
 
6
6
  ### `CommentCard` (default export)
7
7
 
8
- A context-aware card component for displaying user-submitted vendor comments.
8
+ The comment tile: one card for every comment surface, with the header lead, the body length and deletability all supplied as props.
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
- | `context` | `'profile' \| 'vendor'` | `'profile'` | Controls layout and delete permission logic |
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 |
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,14 +20,13 @@ A context-aware card component for displaying user-submitted vendor comments.
20
20
 
21
21
  **Internal logic:**
22
22
 
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`
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.
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"
31
30
  comment={{
32
31
  id: "abc123",
33
32
  content: "Solid RMM tool for small MSPs.",
@@ -38,6 +38,12 @@ 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',
41
47
  }
42
48
 
43
49
  /** Shared badge treatment so an entity's own badges (discipline, level) sit
@@ -37,7 +37,23 @@ interface CommentCardProps {
37
37
  onDeleteComment?: (commentId: string) => void
38
38
  showVendorInfo?: boolean
39
39
  compact?: boolean
40
- context: 'profile' | 'vendor'
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
41
57
  }
42
58
 
43
59
  export function CommentCard({
@@ -46,7 +62,10 @@ export function CommentCard({
46
62
  onDeleteComment,
47
63
  showVendorInfo = true,
48
64
  compact = false,
49
- context = 'profile'
65
+ leadWith = 'commenter',
66
+ fullBody = false,
67
+ footer,
68
+ children
50
69
  }: CommentCardProps) {
51
70
  const { user: currentUser } = useAuth()
52
71
 
@@ -56,32 +75,28 @@ export function CommentCard({
56
75
  }
57
76
 
58
77
  // 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
+ */
59
84
  const canUserDeleteComment = () => {
60
- // If not authenticated, can't delete
61
85
  if (!currentUser) return false
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
86
+ if (typeof comment.canDelete === 'boolean') return comment.canDelete
87
+ return !!comment.user && currentUser.id === comment.user.id
74
88
  }
75
89
 
76
90
  const showDeleteButton = canUserDeleteComment()
77
91
 
78
92
  return (
93
+ <>
79
94
  <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' }}>
80
95
  {/* Comment Header */}
81
96
  <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">
82
97
  {/* Row 1: Info + Timestamp */}
83
98
  <div className="flex items-center gap-2 min-w-0 flex-1">
84
- {context === 'profile' && comment.vendor ? (
99
+ {leadWith === 'vendor' && comment.vendor ? (
85
100
  <>
86
101
  {/* Vendor Button - Icon + Name */}
87
102
  <VendorDisplayButton
@@ -94,7 +109,7 @@ export function CommentCard({
94
109
  {formatActivityTime(comment.createdAt)}
95
110
  </span>
96
111
  </>
97
- ) : context === 'vendor' && comment.user ? (
112
+ ) : comment.user ? (
98
113
  <UserSummary
99
114
  name={comment.user.name}
100
115
  email=""
@@ -152,6 +167,12 @@ export function CommentCard({
152
167
  </h4>
153
168
  </div>
154
169
  )}
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
+ ) : (
155
176
  <div className={compact ? "h-[60px] flex items-center" : "h-[72px] flex items-center"}>
156
177
  <p className="text-h4 text-ods-text-primary line-clamp-3" style={{
157
178
  wordBreak: 'break-word',
@@ -166,7 +187,11 @@ export function CommentCard({
166
187
  {comment.content}
167
188
  </p>
168
189
  </div>
190
+ )}
191
+ {footer ? <div className="pt-2 flex flex-wrap items-center gap-2">{footer}</div> : null}
169
192
  </div>
170
193
  </div>
194
+ {children}
195
+ </>
171
196
  )
172
197
  }
@@ -0,0 +1,117 @@
1
+ 'use client'
2
+
3
+ import { useState, type ReactNode, type RefObject } from 'react'
4
+ import { Button, Input, Label, Textarea } from './ui'
5
+
6
+ export interface CommentComposerDraft {
7
+ title: string
8
+ body: string
9
+ }
10
+
11
+ export interface CommentComposerProps {
12
+ /**
13
+ * Show the title field. The vendor section's comments are titled reviews;
14
+ * a design-doc comment is a Slack message, so it passes `false`.
15
+ */
16
+ withTitle?: boolean
17
+ titleLabel?: string
18
+ titlePlaceholder?: string
19
+ /** For hosts whose empty state focuses the composer ("Be the first!"). */
20
+ titleRef?: RefObject<HTMLInputElement | null>
21
+ bodyLabel?: string
22
+ placeholder?: string
23
+ /** Extra controls under the box — design docs put the comment-type chips here. */
24
+ extras?: ReactNode
25
+ submitLabel?: string
26
+ cancelLabel?: string
27
+ onCancel?: () => void
28
+ pending?: boolean
29
+ /** Accessible name for the body control when there is no visible label. */
30
+ ariaLabel?: string
31
+ /**
32
+ * Return `false` to KEEP the draft — a failed post must never lose what the
33
+ * person typed. Anything else clears it.
34
+ */
35
+ onSubmit: (draft: CommentComposerDraft) => void | boolean | Promise<void | boolean>
36
+ }
37
+
38
+ /**
39
+ * THE comment composer: one bordered box with the field(s) inside and Send
40
+ * beside it (stacked on mobile).
41
+ *
42
+ * One component for every comment surface — the vendor page and the design-doc
43
+ * threads — so the two cannot drift in shape, spacing or submit behaviour. It
44
+ * owns its own draft state, which is what lets both hosts stop hand-rolling
45
+ * one (the vendor page kept title and body in a single newline-joined string).
46
+ */
47
+ export function CommentComposer({
48
+ withTitle = false,
49
+ titleLabel = 'Title',
50
+ titlePlaceholder = 'Your comment title here',
51
+ titleRef,
52
+ bodyLabel = 'Description',
53
+ placeholder = 'Write your comment...',
54
+ extras,
55
+ submitLabel = 'Send',
56
+ cancelLabel = 'Cancel',
57
+ onCancel,
58
+ pending = false,
59
+ ariaLabel,
60
+ onSubmit,
61
+ }: CommentComposerProps) {
62
+ const [title, setTitle] = useState('')
63
+ const [body, setBody] = useState('')
64
+ // A titled composer needs both halves — the host's API rejects a title-only
65
+ // or body-only comment, and disabling Send says so before the round trip.
66
+ const canSubmit = body.trim().length > 0 && (!withTitle || title.trim().length > 0) && !pending
67
+
68
+ const submit = async () => {
69
+ if (!canSubmit) return
70
+ const kept = await onSubmit({ title: title.trim(), body: body.trim() })
71
+ if (kept === false) return
72
+ setTitle('')
73
+ setBody('')
74
+ }
75
+
76
+ return (
77
+ <div className="flex flex-col gap-[var(--spacing-system-xsf)]">
78
+ <div className="flex flex-col gap-[var(--spacing-system-sf)] md:flex-row">
79
+ <div className="flex-1 overflow-hidden rounded-lg border border-ods-border bg-ods-bg">
80
+ {withTitle ? (
81
+ <div className="border-b border-ods-border">
82
+ <Label className="text-badge text-ods-text-secondary px-3 pt-2 pb-1">{titleLabel}</Label>
83
+ <Input
84
+ ref={titleRef}
85
+ value={title}
86
+ onChange={(e) => setTitle(e.target.value)}
87
+ placeholder={titlePlaceholder}
88
+ disabled={pending}
89
+ className="rounded-none border-0 bg-transparent focus-visible:ring-0"
90
+ />
91
+ </div>
92
+ ) : null}
93
+ {withTitle ? <Label className="text-badge text-ods-text-secondary px-3 pt-2 pb-1">{bodyLabel}</Label> : null}
94
+ <Textarea
95
+ value={body}
96
+ onChange={(e) => setBody(e.target.value)}
97
+ placeholder={placeholder}
98
+ disabled={pending}
99
+ aria-label={ariaLabel ?? (withTitle ? bodyLabel : submitLabel)}
100
+ className="min-h-[64px] rounded-none border-0 bg-transparent focus-visible:ring-0"
101
+ />
102
+ </div>
103
+ <div className="flex items-start gap-[var(--spacing-system-xsf)]">
104
+ {onCancel ? (
105
+ <Button type="button" variant="outline" size="small-legacy" onClick={onCancel} disabled={pending}>
106
+ {cancelLabel}
107
+ </Button>
108
+ ) : null}
109
+ <Button type="button" variant="accent" size="small-legacy" onClick={submit} disabled={!canSubmit} loading={pending}>
110
+ {submitLabel}
111
+ </Button>
112
+ </div>
113
+ </div>
114
+ {extras ? <div className="flex flex-wrap items-center gap-[var(--spacing-system-xsf)]">{extras}</div> : null}
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1,84 @@
1
+ 'use client'
2
+
3
+ import { ExternalLink, Asterisk } from 'lucide-react'
4
+ import { toClaudeEmbedUrl } from '../../utils/embed-url-converters'
5
+
6
+ export type ClaudeEmbedKind = 'artifact' | 'design'
7
+
8
+ export interface ClaudeEmbedProps {
9
+ /** Any claude.ai / claude.site url. */
10
+ url: string
11
+ /** Which surface the link points at — only the copy differs. */
12
+ kind?: ClaudeEmbedKind
13
+ /** Heading on the action row. Defaults to the kind's name. */
14
+ title?: string
15
+ /** iframe height (CSS value), matching `FigmaEmbed`'s prop. */
16
+ height?: string
17
+ }
18
+
19
+ const KIND_LABEL: Record<ClaudeEmbedKind, string> = {
20
+ artifact: 'Claude artifact',
21
+ design: 'Claude Design',
22
+ }
23
+
24
+ /**
25
+ * A Claude artifact, embedded the way Figma is — a markdown block, reached
26
+ * through the `{{claude-artifact:URL}}` / `{{claude-design:URL}}` shortcodes,
27
+ * so it renders identically wherever markdown renders: a spec body, a comment,
28
+ * a links rail.
29
+ *
30
+ * A PUBLISHED artifact frames through its `/embed` route (see
31
+ * `toClaudeEmbedUrl`). Everything else cannot be framed at all, so the action
32
+ * row underneath is ALWAYS rendered: what the link is, why the frame above may
33
+ * be blank, and one click to open it.
34
+ *
35
+ * Four things defeat the frame:
36
+ * · a Claude CODE url (`claude.ai/code/artifact/…`) — no Embed settings exist
37
+ * for it, and its `/embed` path answers `frame-ancestors 'self'`;
38
+ * · a Claude Design url, which has no embed route at all;
39
+ * · an artifact whose author has not added this host under Allowed domains;
40
+ * · an http host (mixed content) — it will not paint on `http://localhost`.
41
+ */
42
+ export function ClaudeEmbed({ url, kind = 'artifact', title, height = '480px' }: ClaudeEmbedProps) {
43
+ const label = title ?? KIND_LABEL[kind]
44
+ const embedUrl = toClaudeEmbedUrl(url)
45
+ return (
46
+ <div className="overflow-hidden rounded-lg border border-ods-border bg-ods-card">
47
+ {embedUrl ? (
48
+ <iframe
49
+ src={embedUrl}
50
+ title={label}
51
+ loading="lazy"
52
+ // `allow-same-origin` alongside `allow-scripts` is what Anthropic's own
53
+ // embed snippet uses — an artifact needs both to run.
54
+ sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
55
+ style={{ height }}
56
+ className="block w-full border-0 bg-ods-bg-surface"
57
+ />
58
+ ) : null}
59
+ <a
60
+ href={url}
61
+ target="_blank"
62
+ rel="noopener noreferrer"
63
+ className={`flex items-center gap-[var(--spacing-system-mf)] p-[var(--spacing-system-mf)] no-underline transition-colors hover:bg-ods-bg-surface ${
64
+ embedUrl ? 'border-t border-ods-border' : ''
65
+ }`}
66
+ >
67
+ <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-ods-bg-surface">
68
+ <Asterisk className="h-6 w-6 text-ods-accent" />
69
+ </div>
70
+ <div className="min-w-0 flex-1">
71
+ <p className="truncate text-h4 text-ods-text-primary">{label}</p>
72
+ <p className="truncate text-h6 text-ods-text-secondary">
73
+ {embedUrl
74
+ ? 'claude.ai · blank above? Publish → Embed settings → add this site to Allowed domains'
75
+ : kind === 'artifact'
76
+ ? 'claude.ai · only a published chat artifact can be framed · a Claude Code link opens in a new tab'
77
+ : 'claude.ai · opens in a new tab · claude.ai serves this surface with framing disabled'}
78
+ </p>
79
+ </div>
80
+ <ExternalLink className="h-4 w-4 shrink-0 text-ods-text-secondary" />
81
+ </a>
82
+ </div>
83
+ )
84
+ }
@@ -11,6 +11,8 @@ 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'
14
16
 
15
17
  export { OGLinkPreview, OGLinkErrorBoundary } from './og-link-preview'
16
18
  export type {
@@ -159,7 +159,8 @@ export function ArrayEntryManager<T extends { [key: string]: any }>({
159
159
  </div>
160
160
  )}
161
161
 
162
- <div className="flex-1 space-y-2">
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">
163
164
  {renderLabel && renderLabel(item, index)}
164
165
  <Input
165
166
  placeholder={placeholder}
@@ -14,13 +14,19 @@ 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;
17
22
  }
18
23
 
19
24
  export function ClickUpTasksManager({
20
25
  tasks,
21
26
  onChange,
22
27
  title = 'ClickUp Roadmap Tasks', // Default title
23
- className = ''
28
+ className = '',
29
+ renderLabel
24
30
  }: ClickUpTasksManagerProps) {
25
31
  return (
26
32
  <ArrayEntryManager
@@ -33,6 +39,7 @@ export function ClickUpTasksManager({
33
39
  addButtonText="Add Task"
34
40
  icon={<Lightbulb className="w-5 h-5 text-ods-text-secondary" />}
35
41
  className={className}
42
+ renderLabel={renderLabel}
36
43
  />
37
44
  );
38
45
  }
@@ -16,6 +16,7 @@ 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'
19
20
  export * from './content-loading-container'
20
21
  export * from './dynamic-skeleton'
21
22
  export * from './empty-state'
@@ -68,12 +68,17 @@ 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'
71
75
  className?: string
72
76
  }
73
77
 
74
78
  export function DeliveryRow({
75
79
  item,
76
80
  href,
81
+ target,
77
82
  caption,
78
83
  id,
79
84
  className,
@@ -100,7 +105,10 @@ export function DeliveryRow({
100
105
  </h3>
101
106
  </div>
102
107
  <div className="min-h-[20px] flex items-center">
103
- <p className="text-h5 text-ods-text-secondary uppercase tracking-[-0.28px] truncate">
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>
104
112
  {subtitle}
105
113
  </p>
106
114
  </div>
@@ -155,6 +163,14 @@ export function DeliveryRow({
155
163
  className,
156
164
  )
157
165
 
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
+
158
174
  if (href) {
159
175
  // `Link` is the env-aware embed-shim — delegates to `next/link` on
160
176
  // a Next.js host (soft RSC nav, back-button restores the previous
@@ -100,6 +100,15 @@ 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;
103
112
  /** URL builder for the per-task refresh call after a successful vote. */
104
113
  buildRefreshUrl?: (taskId: string) => string;
105
114
  /** Voting hook options (vote endpoint + storage key). */
@@ -122,18 +131,26 @@ export interface RoadmapGridProps {
122
131
  function RoadmapGridSingle({
123
132
  items,
124
133
  showLeftMargin,
134
+ columns = 2,
125
135
  getVote,
126
136
  onVote,
127
137
  votingTasks,
128
138
  }: {
129
139
  items: RoadmapItem[];
130
140
  showLeftMargin: boolean;
141
+ columns?: 1 | 2;
131
142
  getVote: (taskId: string) => 'up' | 'down' | null;
132
143
  onVote: (taskId: string, voteType: 'up' | 'down') => void;
133
144
  votingTasks: Set<string>;
134
145
  }) {
135
146
  return (
136
- <div className={`grid grid-cols-1 md:grid-cols-2 gap-6 ${showLeftMargin ? 'md:ml-[120px]' : ''}`}>
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
+ >
137
154
  {items.map((item) => (
138
155
  // DOM id + sticky-header scroll offset live ON RoadmapCard's own
139
156
  // outer element (no wrapper div). Anchor mirrors
@@ -157,6 +174,7 @@ export function RoadmapGrid({
157
174
  items,
158
175
  onItemUpdate,
159
176
  showLeftMargin = true,
177
+ columns = 2,
160
178
  buildRefreshUrl = DEFAULT_BUILD_REFRESH_URL,
161
179
  votingOptions,
162
180
  groupByQuarter = false,
@@ -255,6 +273,7 @@ export function RoadmapGrid({
255
273
  <RoadmapGridSingle
256
274
  items={items}
257
275
  showLeftMargin={showLeftMargin}
276
+ columns={columns}
258
277
  getVote={getVote}
259
278
  onVote={handleVote}
260
279
  votingTasks={votingTasks}
@@ -305,6 +324,7 @@ export function RoadmapGrid({
305
324
  <RoadmapGridSingle
306
325
  items={itemsByQuarter[quarter]}
307
326
  showLeftMargin={showLeftMargin}
327
+ columns={columns}
308
328
  getVote={getVote}
309
329
  onVote={handleVote}
310
330
  votingTasks={votingTasks}
@@ -11,7 +11,10 @@ const Card = React.forwardRef<
11
11
  <div
12
12
  ref={ref}
13
13
  className={cn(
14
- "rounded-lg border bg-card text-card-foreground shadow-sm",
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",
15
18
  className
16
19
  )}
17
20
  {...props}
@@ -122,6 +122,7 @@ export * from './content-loader'
122
122
 
123
123
  // Table components
124
124
  export {
125
+ COMPACT_ROW_MIN_HEIGHT_PX,
125
126
  Table, TableCardSkeleton, TableCell,
126
127
  TableDescriptionCell, TableEmptyState, TableHeader,
127
128
  TableRow, TableTimestampCell
@@ -56,3 +56,47 @@ 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
+ });
@@ -17,6 +17,7 @@ import { LinkedInEmbedClient } from '../../../embeds/linkedin-embed-client';
17
17
  import { Video } from '../../../features/video';
18
18
  import { OGLinkPreview, OGLinkErrorBoundary } from '../../../embeds/og-link-preview';
19
19
  import { FigmaEmbed } from '../../../embeds/figma-embed';
20
+ import { ClaudeEmbed } from '../../../embeds/claude-embed';
20
21
  import { MarkdownImage } from '../../../embeds/markdown-image';
21
22
  import { buildStandardLeafRenderers, hasRenderableSrc } from '../base-components';
22
23
  import type { TextSizeElement } from '../text-size';
@@ -146,6 +147,9 @@ export function buildRichEmbedOverrides({
146
147
  if (className === 'figma-embed') {
147
148
  return <FigmaEmbed url={props['data-figma-url']} height="70vh" />;
148
149
  }
150
+ if (className === 'claude-embed') {
151
+ return <ClaudeEmbed url={props['data-url']} kind={props['data-kind'] === 'design' ? 'design' : 'artifact'} />;
152
+ }
149
153
  if (className === 'linkedin-embed') {
150
154
  return <LinkedInEmbedClient url={props['data-post-url']} />;
151
155
  }
@@ -55,10 +55,22 @@ export const processShortcodes = (content: string): string => {
55
55
  : `https://twitter.com/twitter/status/${tweetInput}`;
56
56
  return `\n\n<div class="tweet-embed" data-tweet-url="${escapeAttr(tweetUrl)}"></div>\n\n`;
57
57
  })
58
+ // Figma by file key (spec grammar): {{figma:FILE_KEY[:NODE_ID]}} → rewritten to the
59
+ // canonical URL form so the {{figma:URL}} rule below owns the rendering.
60
+ .replace(/\{\{figma:(?!https?:)([A-Za-z0-9]+)(?::([0-9]+[-:][0-9]+))?\}\}/g, (match, key, node) => {
61
+ const nodeParam = node ? `?node-id=${String(node).replace(':', '-')}` : '';
62
+ return `{{figma:https://www.figma.com/design/${key}${nodeParam}}}`;
63
+ })
58
64
  // Figma embeds: {{figma:URL}}
59
65
  .replace(/\{\{figma:([^}]+)\}\}/g, (match, url) => {
60
66
  return `\n\n<div class="figma-embed" data-figma-url="${escapeAttr(url.trim())}"></div>\n\n`;
61
67
  })
68
+ // Claude artifact / Claude Design: {{claude-artifact:URL}} / {{claude-design:URL}}
69
+ // — the same shape as figma, so a Claude link is a markdown BLOCK wherever
70
+ // markdown renders, never a bespoke card one surface hand-assembles.
71
+ .replace(/\{\{claude-(artifact|design):([^}]+)\}\}/g, (match, kind, url) => {
72
+ return `\n\n<div class="claude-embed" data-url="${escapeAttr(url.trim())}" data-kind="${kind}"></div>\n\n`;
73
+ })
62
74
  // LinkedIn embeds: {{linkedin:POST_URL}}
63
75
  .replace(/\{\{linkedin:([^}]+)\}\}/g, (match, url) => {
64
76
  return `\n\n<div class="linkedin-embed" data-post-url="${escapeAttr(url.trim())}"></div>\n\n`;