@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.
- package/dist/{chunk-PKCZL56Y.cjs → chunk-4H7L4OSR.cjs} +4 -4
- package/dist/{chunk-PKCZL56Y.cjs.map → chunk-4H7L4OSR.cjs.map} +1 -1
- package/dist/chunk-5I3BSQ6D.js +2 -0
- package/dist/{chunk-6OPEULTD.cjs → chunk-BKDSNVGH.cjs} +9 -9
- package/dist/{chunk-6OPEULTD.cjs.map → chunk-BKDSNVGH.cjs.map} +1 -1
- package/dist/{chunk-OYFXHET4.cjs → chunk-BWMVI2KD.cjs} +7 -7
- package/dist/{chunk-OYFXHET4.cjs.map → chunk-BWMVI2KD.cjs.map} +1 -1
- package/dist/{chunk-YFLSOYX4.cjs → chunk-DLSDYLY3.cjs} +11 -11
- package/dist/{chunk-YFLSOYX4.cjs.map → chunk-DLSDYLY3.cjs.map} +1 -1
- package/dist/{chunk-NQ6UF4LP.js → chunk-EQEMZU45.js} +4 -4
- package/dist/{chunk-FG4BSJ4A.cjs → chunk-GG2HJQW3.cjs} +26 -26
- package/dist/{chunk-FG4BSJ4A.cjs.map → chunk-GG2HJQW3.cjs.map} +1 -1
- package/dist/{chunk-O3SEC45L.js → chunk-GG6CAELN.js} +4 -4
- package/dist/{chunk-J7R3VZL5.cjs → chunk-GZL5SAUI.cjs} +19 -15
- package/dist/chunk-GZL5SAUI.cjs.map +1 -0
- package/dist/{chunk-ASULU3T2.js → chunk-H4RCJ4IB.js} +7 -3
- package/dist/{chunk-ASULU3T2.js.map → chunk-H4RCJ4IB.js.map} +1 -1
- package/dist/{chunk-U5EVZLL5.cjs → chunk-KHEOXHF4.cjs} +5 -5
- package/dist/{chunk-U5EVZLL5.cjs.map → chunk-KHEOXHF4.cjs.map} +1 -1
- package/dist/{chunk-Z4VU6MJ4.js → chunk-LOWWLH2W.js} +2 -2
- package/dist/{chunk-NUKQWLOG.js → chunk-MIKGZY7V.js} +5 -5
- package/dist/{chunk-BK7V6CH5.js → chunk-MSUSONUK.js} +3297 -3209
- package/dist/chunk-MSUSONUK.js.map +1 -0
- package/dist/{chunk-PT4GS4CE.cjs → chunk-NAYXROCV.cjs} +3 -3
- package/dist/{chunk-PT4GS4CE.cjs.map → chunk-NAYXROCV.cjs.map} +1 -1
- package/dist/{chunk-G2VDS3M5.js → chunk-OEN3GTRL.js} +2 -2
- package/dist/{chunk-XFI5PCER.cjs → chunk-OTDVQBLI.cjs} +31 -31
- package/dist/{chunk-XFI5PCER.cjs.map → chunk-OTDVQBLI.cjs.map} +1 -1
- package/dist/{chunk-WGDZW237.cjs → chunk-PBH2BQYR.cjs} +86 -86
- package/dist/{chunk-WGDZW237.cjs.map → chunk-PBH2BQYR.cjs.map} +1 -1
- package/dist/{chunk-E6PW7GRQ.cjs → chunk-PZH3CZTJ.cjs} +89 -75
- package/dist/chunk-PZH3CZTJ.cjs.map +1 -0
- package/dist/{chunk-GEC2NHQH.cjs → chunk-QWVON3LG.cjs} +20 -20
- package/dist/{chunk-GEC2NHQH.cjs.map → chunk-QWVON3LG.cjs.map} +1 -1
- package/dist/{chunk-X4JKXYY5.js → chunk-SQ53GVCG.js} +2 -2
- package/dist/{chunk-AZPUAPXW.cjs → chunk-TA5P2M7V.cjs} +796 -708
- package/dist/chunk-TA5P2M7V.cjs.map +1 -0
- package/dist/chunk-UGAHDPNU.cjs +2 -0
- package/dist/{chunk-ZTM35Q76.cjs.map → chunk-UGAHDPNU.cjs.map} +1 -1
- package/dist/{chunk-T3P56JKN.js → chunk-XPLZB74T.js} +2 -2
- package/dist/{chunk-CQNK6RS6.js → chunk-XSK7ZQ3C.js} +2 -2
- package/dist/{chunk-TDYF6Q2D.js → chunk-XZNKPEWB.js} +2 -2
- package/dist/{chunk-VVDLVCPW.cjs → chunk-Y6IGG6RZ.cjs} +39 -39
- package/dist/{chunk-VVDLVCPW.cjs.map → chunk-Y6IGG6RZ.cjs.map} +1 -1
- package/dist/{chunk-7D62XVHH.js → chunk-YVOMHB73.js} +35 -21
- package/dist/chunk-YVOMHB73.js.map +1 -0
- package/dist/{chunk-CP7TMQBH.js → chunk-ZUVVJ24E.js} +2 -2
- package/dist/{chunk-DPMOEYEM.js → chunk-ZYC4O76Y.js} +2 -2
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/entity-cards/employee-entry-card.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/comment-card.d.ts +18 -2
- package/dist/components/comment-card.d.ts.map +1 -1
- package/dist/components/comment-composer.d.ts +42 -0
- package/dist/components/comment-composer.d.ts.map +1 -0
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/claude-embed.d.ts +31 -0
- package/dist/components/embeds/claude-embed.d.ts.map +1 -0
- package/dist/components/embeds/index.cjs +6 -4
- package/dist/components/embeds/index.cjs.map +1 -1
- package/dist/components/embeds/index.d.ts +2 -0
- package/dist/components/embeds/index.d.ts.map +1 -1
- package/dist/components/embeds/index.js +5 -3
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/array-entry-manager.d.ts.map +1 -1
- package/dist/components/features/clickup-tasks-manager.d.ts +6 -1
- package/dist/components/features/clickup-tasks-manager.d.ts.map +1 -1
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +281 -204
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +647 -570
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +30 -30
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/shared/delivery/delivery-row.d.ts +5 -1
- package/dist/components/shared/delivery/delivery-row.d.ts.map +1 -1
- package/dist/components/shared/roadmap/roadmap-grid.d.ts +10 -1
- package/dist/components/shared/roadmap/roadmap-grid.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/card.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +4 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -1
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +3 -1
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -1
- package/dist/components/ui/table/index.d.ts +1 -1
- package/dist/components/ui/table/index.d.ts.map +1 -1
- package/dist/components/ui/table/table-row.d.ts.map +1 -1
- package/dist/components/ui/table/table-skeleton.d.ts +15 -2
- package/dist/components/ui/table/table-skeleton.d.ts.map +1 -1
- package/dist/components/ui/table/table.d.ts.map +1 -1
- package/dist/components/ui/table/types.d.ts +2 -0
- package/dist/components/ui/table/types.d.ts.map +1 -1
- package/dist/index.cjs +6 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -1
- package/dist/types/department.d.ts +41 -0
- package/dist/types/department.d.ts.map +1 -0
- package/dist/types/design-doc.d.ts +247 -0
- package/dist/types/design-doc.d.ts.map +1 -0
- package/dist/types/employee.d.ts +25 -2
- package/dist/types/employee.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/marketing.d.ts +1 -1
- package/dist/types/marketing.d.ts.map +1 -1
- package/dist/types/profile.d.ts +2 -1
- package/dist/types/profile.d.ts.map +1 -1
- package/dist/types/team.d.ts +15 -1
- package/dist/types/team.d.ts.map +1 -1
- package/dist/types/user.d.ts +3 -1
- package/dist/types/user.d.ts.map +1 -1
- package/dist/utils/embed-url-converters.d.ts +17 -0
- package/dist/utils/embed-url-converters.d.ts.map +1 -1
- package/dist/utils/index.cjs +12 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js +12 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/.comment-card.md +4 -5
- package/src/components/chat/entity-cards/employee-entry-card.tsx +6 -0
- package/src/components/comment-card.tsx +42 -17
- package/src/components/comment-composer.tsx +117 -0
- package/src/components/embeds/claude-embed.tsx +84 -0
- package/src/components/embeds/index.ts +2 -0
- package/src/components/features/array-entry-manager.tsx +2 -1
- package/src/components/features/clickup-tasks-manager.tsx +8 -1
- package/src/components/index.ts +1 -0
- package/src/components/shared/delivery/delivery-row.tsx +17 -1
- package/src/components/shared/roadmap/roadmap-grid.tsx +21 -1
- package/src/components/ui/card.tsx +4 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/markdown/rich/__tests__/shortcodes.test.ts +44 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +4 -0
- package/src/components/ui/markdown/rich/shortcodes.ts +12 -0
- package/src/components/ui/table/index.ts +1 -1
- package/src/components/ui/table/table-row.tsx +7 -2
- package/src/components/ui/table/table-skeleton.tsx +20 -3
- package/src/components/ui/table/table.tsx +1 -0
- package/src/components/ui/table/types.ts +2 -0
- package/src/types/department.ts +44 -0
- package/src/types/design-doc.ts +295 -0
- package/src/types/employee.ts +27 -2
- package/src/types/index.ts +2 -0
- package/src/types/marketing.ts +1 -0
- package/src/types/profile.ts +3 -1
- package/src/types/team.ts +17 -1
- package/src/types/user.ts +4 -1
- package/src/utils/__tests__/claude-embed-url.test.ts +42 -0
- package/src/utils/embed-url-converters.ts +32 -0
- package/dist/chunk-7D62XVHH.js.map +0 -1
- package/dist/chunk-AZPUAPXW.cjs.map +0 -1
- package/dist/chunk-BK7V6CH5.js.map +0 -1
- package/dist/chunk-E6PW7GRQ.cjs.map +0 -1
- package/dist/chunk-J7R3VZL5.cjs.map +0 -1
- package/dist/chunk-MVWSLZG7.js +0 -2
- package/dist/chunk-ZTM35Q76.cjs +0 -2
- /package/dist/{chunk-MVWSLZG7.js.map → chunk-5I3BSQ6D.js.map} +0 -0
- /package/dist/{chunk-NQ6UF4LP.js.map → chunk-EQEMZU45.js.map} +0 -0
- /package/dist/{chunk-O3SEC45L.js.map → chunk-GG6CAELN.js.map} +0 -0
- /package/dist/{chunk-Z4VU6MJ4.js.map → chunk-LOWWLH2W.js.map} +0 -0
- /package/dist/{chunk-NUKQWLOG.js.map → chunk-MIKGZY7V.js.map} +0 -0
- /package/dist/{chunk-G2VDS3M5.js.map → chunk-OEN3GTRL.js.map} +0 -0
- /package/dist/{chunk-X4JKXYY5.js.map → chunk-SQ53GVCG.js.map} +0 -0
- /package/dist/{chunk-T3P56JKN.js.map → chunk-XPLZB74T.js.map} +0 -0
- /package/dist/{chunk-CQNK6RS6.js.map → chunk-XSK7ZQ3C.js.map} +0 -0
- /package/dist/{chunk-TDYF6Q2D.js.map → chunk-XZNKPEWB.js.map} +0 -0
- /package/dist/{chunk-CP7TMQBH.js.map → chunk-ZUVVJ24E.js.map} +0 -0
- /package/dist/{chunk-DPMOEYEM.js.map → chunk-ZYC4O76Y.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
<!-- source-hash: 2dc22952dfcc6e283cfc3b6d4d636ade -->
|
|
2
|
-
Renders a styled comment card that adapts its
|
|
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
|
-
|
|
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
|
-
| `
|
|
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()` —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
) :
|
|
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
|
-
|
|
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
|
}
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
@@ -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('"');
|
|
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`;
|