@flamingo-stack/openframe-frontend-core 0.0.549 → 0.0.550-1809.4789.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 (132) hide show
  1. package/dist/{chunk-3UWTT6YZ.cjs → chunk-2XV6CE6M.cjs} +31 -31
  2. package/dist/{chunk-3UWTT6YZ.cjs.map → chunk-2XV6CE6M.cjs.map} +1 -1
  3. package/dist/{chunk-ZZX4FQSW.cjs → chunk-2ZJ4BJ3U.cjs} +78 -18
  4. package/dist/chunk-2ZJ4BJ3U.cjs.map +1 -0
  5. package/dist/{chunk-TBPENWIC.cjs → chunk-5B53E3UM.cjs} +3 -3
  6. package/dist/{chunk-TBPENWIC.cjs.map → chunk-5B53E3UM.cjs.map} +1 -1
  7. package/dist/{chunk-KOBSYCES.js → chunk-5IJPWW6O.js} +2 -2
  8. package/dist/{chunk-JHW6JAY2.cjs → chunk-6AELOXOY.cjs} +61 -61
  9. package/dist/{chunk-JHW6JAY2.cjs.map → chunk-6AELOXOY.cjs.map} +1 -1
  10. package/dist/{chunk-SYALTJFE.cjs → chunk-7374NQNY.cjs} +50 -37
  11. package/dist/chunk-7374NQNY.cjs.map +1 -0
  12. package/dist/{chunk-JDGC7ULB.js → chunk-ABIMH2FX.js} +2 -2
  13. package/dist/{chunk-UGAYDMIX.js → chunk-AOXFPWVN.js} +5 -5
  14. package/dist/{chunk-IHLJPOBD.js → chunk-AUY2RNZM.js} +2 -2
  15. package/dist/{chunk-YXCG5OWQ.js → chunk-EJZOTT75.js} +4 -4
  16. package/dist/{chunk-J7R2TX3Z.js → chunk-ETFCZVXU.js} +2 -2
  17. package/dist/{chunk-3GNLB5ZE.cjs → chunk-FHPSG36S.cjs} +4 -4
  18. package/dist/{chunk-3GNLB5ZE.cjs.map → chunk-FHPSG36S.cjs.map} +1 -1
  19. package/dist/{chunk-LNFFNT7U.js → chunk-FRYJCWU4.js} +2 -2
  20. package/dist/{chunk-7MM4CVPX.js → chunk-FS3GRXES.js} +2 -2
  21. package/dist/{chunk-ZIKIK2HY.js → chunk-I3LZTRRF.js} +2 -2
  22. package/dist/{chunk-3E6NXFCC.cjs → chunk-J2TWUHPW.cjs} +5 -5
  23. package/dist/{chunk-3E6NXFCC.cjs.map → chunk-J2TWUHPW.cjs.map} +1 -1
  24. package/dist/{chunk-RLFIDM7O.js → chunk-JC5KBKQO.js} +2 -2
  25. package/dist/{chunk-3UBQOT3I.cjs → chunk-JQ3UYEYL.cjs} +14 -14
  26. package/dist/{chunk-3UBQOT3I.cjs.map → chunk-JQ3UYEYL.cjs.map} +1 -1
  27. package/dist/{chunk-PNVOJORK.cjs → chunk-MYHN4PR7.cjs} +27 -27
  28. package/dist/{chunk-PNVOJORK.cjs.map → chunk-MYHN4PR7.cjs.map} +1 -1
  29. package/dist/{chunk-G4HJJTOJ.js → chunk-O2YEPKWP.js} +6 -6
  30. package/dist/{chunk-JV4SAJLA.cjs → chunk-OV5W7QE2.cjs} +87 -87
  31. package/dist/{chunk-JV4SAJLA.cjs.map → chunk-OV5W7QE2.cjs.map} +1 -1
  32. package/dist/{chunk-25QVYJ2Q.cjs → chunk-PPQJKUI2.cjs} +26 -26
  33. package/dist/{chunk-25QVYJ2Q.cjs.map → chunk-PPQJKUI2.cjs.map} +1 -1
  34. package/dist/{chunk-U4TKV7QM.cjs → chunk-PVDSJKGL.cjs} +9 -9
  35. package/dist/{chunk-U4TKV7QM.cjs.map → chunk-PVDSJKGL.cjs.map} +1 -1
  36. package/dist/{chunk-NSHENOGO.js → chunk-QL2UEXPY.js} +3474 -3414
  37. package/dist/chunk-QL2UEXPY.js.map +1 -0
  38. package/dist/{chunk-J4VSR65F.js → chunk-R4TNUBO4.js} +15 -2
  39. package/dist/chunk-R4TNUBO4.js.map +1 -0
  40. package/dist/{chunk-BCM7EU7R.cjs → chunk-RIF6R54C.cjs} +11 -11
  41. package/dist/{chunk-BCM7EU7R.cjs.map → chunk-RIF6R54C.cjs.map} +1 -1
  42. package/dist/{chunk-27EVFHSR.js → chunk-TSU7MYQC.js} +4 -4
  43. package/dist/{chunk-AIGSKRHR.cjs → chunk-ZDRGQCKN.cjs} +7 -7
  44. package/dist/{chunk-AIGSKRHR.cjs.map → chunk-ZDRGQCKN.cjs.map} +1 -1
  45. package/dist/components/case-studies/index.cjs +8 -8
  46. package/dist/components/case-studies/index.js +2 -2
  47. package/dist/components/chat/entity-cards/employee-entry-card.d.ts +56 -0
  48. package/dist/components/chat/entity-cards/employee-entry-card.d.ts.map +1 -0
  49. package/dist/components/chat/entity-cards/how-i-work-card.d.ts +34 -0
  50. package/dist/components/chat/entity-cards/how-i-work-card.d.ts.map +1 -0
  51. package/dist/components/chat/entity-cards/index.d.ts +4 -0
  52. package/dist/components/chat/entity-cards/index.d.ts.map +1 -1
  53. package/dist/components/chat/entity-cards/what-i-shipped-card.d.ts +10 -22
  54. package/dist/components/chat/entity-cards/what-i-shipped-card.d.ts.map +1 -1
  55. package/dist/components/chat/index.cjs +12 -2
  56. package/dist/components/chat/index.cjs.map +1 -1
  57. package/dist/components/chat/index.js +11 -1
  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/index.cjs +3 -3
  63. package/dist/components/embeds/index.js +2 -2
  64. package/dist/components/faq/index.cjs +3 -3
  65. package/dist/components/faq/index.js +2 -2
  66. package/dist/components/features/index.cjs +2 -2
  67. package/dist/components/features/index.js +1 -1
  68. package/dist/components/features/status-filter-component.d.ts +10 -1
  69. package/dist/components/features/status-filter-component.d.ts.map +1 -1
  70. package/dist/components/help-center-pages/index.cjs +22 -22
  71. package/dist/components/help-center-pages/index.js +13 -13
  72. package/dist/components/index.cjs +142 -132
  73. package/dist/components/index.cjs.map +1 -1
  74. package/dist/components/index.js +21 -11
  75. package/dist/components/index.js.map +1 -1
  76. package/dist/components/meeting-scheduler/index.cjs +34 -34
  77. package/dist/components/meeting-scheduler/index.js +3 -3
  78. package/dist/components/navigation/index.cjs +2 -2
  79. package/dist/components/navigation/index.js +1 -1
  80. package/dist/components/onboarding-guides/index.cjs +5 -5
  81. package/dist/components/onboarding-guides/index.js +4 -4
  82. package/dist/components/related-content/index.cjs +3 -3
  83. package/dist/components/related-content/index.js +2 -2
  84. package/dist/components/related-content/related-content-section.d.ts.map +1 -1
  85. package/dist/components/tickets/index.cjs +6 -6
  86. package/dist/components/tickets/index.js +5 -5
  87. package/dist/components/ui/index.cjs +12 -2
  88. package/dist/components/ui/index.cjs.map +1 -1
  89. package/dist/components/ui/index.js +11 -1
  90. package/dist/index.cjs +12 -2
  91. package/dist/index.cjs.map +1 -1
  92. package/dist/index.js +11 -1
  93. package/dist/types/marketing.d.ts +1 -1
  94. package/dist/types/marketing.d.ts.map +1 -1
  95. package/dist/types/video-processing.d.ts +2 -1
  96. package/dist/types/video-processing.d.ts.map +1 -1
  97. package/dist/utils/content-ref-groups.d.ts.map +1 -1
  98. package/dist/utils/index.cjs +2 -1
  99. package/dist/utils/index.cjs.map +1 -1
  100. package/dist/utils/index.js +2 -1
  101. package/dist/utils/index.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/components/chat/entity-cards/employee-entry-card.tsx +139 -0
  104. package/src/components/chat/entity-cards/how-i-work-card.tsx +65 -0
  105. package/src/components/chat/entity-cards/index.ts +4 -0
  106. package/src/components/chat/entity-cards/what-i-shipped-card.tsx +15 -78
  107. package/src/components/features/status-filter-component.tsx +12 -2
  108. package/src/components/related-content/related-content-section.tsx +18 -0
  109. package/src/stories/EmployeeEntryCard.stories.tsx +95 -0
  110. package/src/stories/HowIWorkCard.stories.tsx +47 -0
  111. package/src/stories/StatusFilterComponent.stories.tsx +74 -0
  112. package/src/stories/WhatIShippedCard.stories.tsx +40 -0
  113. package/src/stories/__fixtures__/chat-cards.ts +40 -0
  114. package/src/types/marketing.ts +1 -0
  115. package/src/types/video-processing.ts +2 -0
  116. package/src/utils/content-ref-groups.ts +1 -0
  117. package/dist/chunk-J4VSR65F.js.map +0 -1
  118. package/dist/chunk-NSHENOGO.js.map +0 -1
  119. package/dist/chunk-SYALTJFE.cjs.map +0 -1
  120. package/dist/chunk-ZZX4FQSW.cjs.map +0 -1
  121. /package/dist/{chunk-KOBSYCES.js.map → chunk-5IJPWW6O.js.map} +0 -0
  122. /package/dist/{chunk-JDGC7ULB.js.map → chunk-ABIMH2FX.js.map} +0 -0
  123. /package/dist/{chunk-UGAYDMIX.js.map → chunk-AOXFPWVN.js.map} +0 -0
  124. /package/dist/{chunk-IHLJPOBD.js.map → chunk-AUY2RNZM.js.map} +0 -0
  125. /package/dist/{chunk-YXCG5OWQ.js.map → chunk-EJZOTT75.js.map} +0 -0
  126. /package/dist/{chunk-J7R2TX3Z.js.map → chunk-ETFCZVXU.js.map} +0 -0
  127. /package/dist/{chunk-LNFFNT7U.js.map → chunk-FRYJCWU4.js.map} +0 -0
  128. /package/dist/{chunk-7MM4CVPX.js.map → chunk-FS3GRXES.js.map} +0 -0
  129. /package/dist/{chunk-ZIKIK2HY.js.map → chunk-I3LZTRRF.js.map} +0 -0
  130. /package/dist/{chunk-RLFIDM7O.js.map → chunk-JC5KBKQO.js.map} +0 -0
  131. /package/dist/{chunk-G4HJJTOJ.js.map → chunk-O2YEPKWP.js.map} +0 -0
  132. /package/dist/{chunk-27EVFHSR.js.map → chunk-TSU7MYQC.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.549",
3
+ "version": "0.0.550-1809.4789.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,139 @@
1
+ import React from 'react'
2
+ import { AdminContentCard } from './admin-content-card'
3
+ import { getProxiedImageUrl } from '../../../utils/image-proxy-stub'
4
+
5
+ /** The fields every people-hub employee entry shares. Entity-specific bindings
6
+ * (`WhatIShippedCard`, `HowIWorkCard`) map their own date column to `dateLabel`
7
+ * and pass the rest through unchanged. */
8
+ export interface EmployeeEntryCardData {
9
+ title?: string | null
10
+ summary?: string | null
11
+ status?: string | null
12
+ featured_image?: string | null
13
+ main_video_thumbnail?: string | null
14
+ author?: { full_name?: string | null; avatar_url?: string | null } | null
15
+ }
16
+
17
+ export interface EmployeeEntryCardProps {
18
+ entry: EmployeeEntryCardData
19
+ /** Already-formatted date shown in the meta row. Bindings own the format so
20
+ * the UTC-pin convention stays in `utils/format`, not in the card. */
21
+ dateLabel?: string | null
22
+ /** Extra badges rendered after the status badge (e.g. discipline / level). */
23
+ extraBadges?: React.ReactNode
24
+ /** Fallback title when the entry has none — names the entity in the empty case. */
25
+ untitledLabel?: string
26
+ /** OG fallback cover. Caller computes it (hub: `useOgPlaceholderUrl`; related
27
+ * rail: `extras.buildOgPlaceholderUrl`). */
28
+ placeholderUrl?: string | null
29
+ /** Owner action row (dashboard). Omit for a read-only card. */
30
+ actions?: React.ReactNode
31
+ /** When provided, the WHOLE card becomes a link (related-rail click-through).
32
+ * Don't combine with `actions` (nested interactive). */
33
+ anchorProps?: React.AnchorHTMLAttributes<HTMLAnchorElement>
34
+ className?: string
35
+ }
36
+
37
+ const STATUS_BADGE_CLASS: Record<string, string> = {
38
+ published: 'bg-ods-success-secondary text-ods-success',
39
+ draft: 'bg-ods-warning-secondary text-ods-warning',
40
+ archived: 'bg-ods-border text-ods-text-secondary',
41
+ }
42
+
43
+ /** Shared badge treatment so an entity's own badges (discipline, level) sit
44
+ * flush with the status badge instead of re-deriving the classes per card. */
45
+ export function EmployeeEntryBadge({ children }: { children: React.ReactNode }) {
46
+ return (
47
+ <span className="px-2 py-1 rounded text-xs font-medium bg-ods-card border border-ods-border text-ods-text-secondary">
48
+ {children}
49
+ </span>
50
+ )
51
+ }
52
+
53
+ /**
54
+ * THE people-hub employee-entry card. Wraps `AdminContentCard` with the canonical
55
+ * mapping (cover = featured_image || main_video_thumbnail, OG placeholder
56
+ * fallback, title, 140-char summary, status badge, author avatar+name + a date
57
+ * label). Every entity that renders "an employee's entry" binds to this one
58
+ * engine — What I Shipped and How I Work differ only in which date column they
59
+ * format and which extra badges they add, so the card itself cannot drift
60
+ * between the dashboard and the related-content rail.
61
+ */
62
+ export function EmployeeEntryCard({
63
+ entry,
64
+ dateLabel,
65
+ extraBadges,
66
+ untitledLabel = 'Untitled entry',
67
+ placeholderUrl,
68
+ actions,
69
+ anchorProps,
70
+ className,
71
+ }: EmployeeEntryCardProps) {
72
+ const card = (
73
+ <AdminContentCard
74
+ imageUrl={entry.featured_image || entry.main_video_thumbnail}
75
+ placeholderUrl={placeholderUrl}
76
+ title={entry.title || untitledLabel}
77
+ summary={(entry.summary ?? '').slice(0, 140) || 'No description'}
78
+ badges={
79
+ entry.status || extraBadges ? (
80
+ <>
81
+ {entry.status ? (
82
+ <span
83
+ className={`px-2 py-1 rounded text-xs font-medium ${
84
+ STATUS_BADGE_CLASS[entry.status] ?? 'bg-ods-card border border-ods-border text-ods-text-secondary'
85
+ }`}
86
+ >
87
+ {entry.status}
88
+ </span>
89
+ ) : null}
90
+ {extraBadges}
91
+ </>
92
+ ) : null
93
+ }
94
+ meta={
95
+ <>
96
+ <span className="flex items-center gap-2 min-w-0">
97
+ {entry.author?.avatar_url ? (
98
+ <img src={getProxiedImageUrl(entry.author.avatar_url) ?? entry.author.avatar_url} alt="" className="h-5 w-5 rounded-full object-cover shrink-0" />
99
+ ) : null}
100
+ <span className="truncate">{entry.author?.full_name ?? ''}</span>
101
+ </span>
102
+ {dateLabel ? <span>{dateLabel}</span> : null}
103
+ </>
104
+ }
105
+ actions={actions}
106
+ className={className}
107
+ />
108
+ )
109
+ return anchorProps ? (
110
+ <a {...anchorProps} className="block h-full">
111
+ {card}
112
+ </a>
113
+ ) : (
114
+ card
115
+ )
116
+ }
117
+
118
+ /** Loading skeleton matching EmployeeEntryCard's AdminContentCard shape (3:2 cover
119
+ * + title / summary / meta lines). Used by the related-content rail while a
120
+ * group hydrates so there's no shape jump when the real card lands. */
121
+ export function EmployeeEntryCardSkeleton({ className }: { className?: string }) {
122
+ // Same convention as BlogCardSkeleton et al.: animate-pulse on the container,
123
+ // `bg-ods-bg` placeholder blocks, flex-grow body with an `mt-auto` avatar+name
124
+ // row. Shape mirrors EmployeeEntryCard's AdminContentCard (rounded-2xl, 3:2 cover).
125
+ return (
126
+ <div className={`group bg-ods-card border border-ods-border rounded-2xl overflow-hidden h-full flex flex-col animate-pulse ${className ?? ''}`}>
127
+ <div className="aspect-[3/2] bg-ods-bg" />
128
+ <div className="p-4 flex flex-col flex-grow space-y-3">
129
+ <div className="h-5 w-3/4 bg-ods-bg rounded" />
130
+ <div className="h-3 w-full bg-ods-bg/60 rounded" />
131
+ <div className="h-3 w-4/5 bg-ods-bg/60 rounded" />
132
+ <div className="mt-auto flex items-center gap-2">
133
+ <div className="h-8 w-8 rounded-full bg-ods-bg" />
134
+ <div className="h-3 w-24 bg-ods-bg/60 rounded" />
135
+ </div>
136
+ </div>
137
+ </div>
138
+ )
139
+ }
@@ -0,0 +1,65 @@
1
+ import React from 'react'
2
+ import {
3
+ EmployeeEntryCard,
4
+ EmployeeEntryCardSkeleton,
5
+ EmployeeEntryBadge,
6
+ type EmployeeEntryCardData,
7
+ } from './employee-entry-card'
8
+ import { formatDateUTC } from '../../../utils/format'
9
+
10
+ /** Minimal row shape the card renders. Both the hub dashboard entry and the
11
+ * related-content hydrated row satisfy it structurally. */
12
+ export interface HowIWorkCardData extends EmployeeEntryCardData {
13
+ session_date?: string | null
14
+ /** Which craft the session is about — surfaced as a badge so a reader
15
+ * scanning the grid can tell a sales workflow from an engineering one. */
16
+ discipline?: string | null
17
+ }
18
+
19
+ export interface HowIWorkCardProps {
20
+ entry: HowIWorkCardData
21
+ /** OG fallback cover. Caller computes it (hub: `useOgPlaceholderUrl`; related
22
+ * rail: `extras.buildOgPlaceholderUrl`). */
23
+ placeholderUrl?: string | null
24
+ /** Owner action row (dashboard). Omit for a read-only card. */
25
+ actions?: React.ReactNode
26
+ /** When provided, the WHOLE card becomes a link (related-rail click-through).
27
+ * Don't combine with `actions` (nested interactive). */
28
+ anchorProps?: React.AnchorHTMLAttributes<HTMLAnchorElement>
29
+ className?: string
30
+ }
31
+
32
+ /** `ops` is an initialism, so the generic capitalize would render it "Ops" —
33
+ * fine — but `session_date`-style multiword disciplines need the underscore
34
+ * stripped too. One place, so the badge matches the filter chip exactly. */
35
+ function disciplineLabel(discipline: string): string {
36
+ return discipline.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())
37
+ }
38
+
39
+ /**
40
+ * THE single "How I Work" card — a thin binding over `EmployeeEntryCard` that
41
+ * maps the session date to the shared meta date and adds the discipline badge.
42
+ * Used by BOTH the people-hub dashboard (with owner `actions`) and the
43
+ * related-content rail (with `anchorProps`), so the card cannot drift between
44
+ * the two surfaces.
45
+ */
46
+ export function HowIWorkCard({ entry, placeholderUrl, actions, anchorProps, className }: HowIWorkCardProps) {
47
+ return (
48
+ <EmployeeEntryCard
49
+ entry={entry}
50
+ // `formatDateUTC` anchors date-only strings to UTC midnight, which is what
51
+ // keeps the label from shifting a day between server and client render.
52
+ dateLabel={entry.session_date ? formatDateUTC(entry.session_date, { fallback: '' }) || null : null}
53
+ extraBadges={entry.discipline ? <EmployeeEntryBadge>{disciplineLabel(entry.discipline)}</EmployeeEntryBadge> : null}
54
+ untitledLabel="Untitled session"
55
+ placeholderUrl={placeholderUrl}
56
+ actions={actions}
57
+ anchorProps={anchorProps}
58
+ className={className}
59
+ />
60
+ )
61
+ }
62
+
63
+ /** Loading skeleton matching HowIWorkCard's shape. Shared with every other
64
+ * employee-entry card so the rail's placeholder never diverges from the card. */
65
+ export const HowIWorkCardSkeleton = EmployeeEntryCardSkeleton
@@ -27,8 +27,12 @@ export {
27
27
  type CoverImageFallback,
28
28
  } from './use-cover-image-fallback'
29
29
  export { AdminContentCard } from './admin-content-card'
30
+ export { EmployeeEntryCard, EmployeeEntryCardSkeleton, EmployeeEntryBadge } from './employee-entry-card'
31
+ export type { EmployeeEntryCardData, EmployeeEntryCardProps } from './employee-entry-card'
30
32
  export { WhatIShippedCard, WhatIShippedCardSkeleton } from './what-i-shipped-card'
31
33
  export type { WhatIShippedCardData, WhatIShippedCardProps } from './what-i-shipped-card'
34
+ export { HowIWorkCard, HowIWorkCardSkeleton } from './how-i-work-card'
35
+ export type { HowIWorkCardData, HowIWorkCardProps } from './how-i-work-card'
32
36
 
33
37
  // Moved-into-subdir flat cards
34
38
  export { BlockCard, type BlockCardProps } from './block-card'
@@ -1,18 +1,11 @@
1
1
  import React from 'react'
2
- import { AdminContentCard } from './admin-content-card'
2
+ import { EmployeeEntryCard, EmployeeEntryCardSkeleton, type EmployeeEntryCardData } from './employee-entry-card'
3
3
  import { formatEntryMonthUTC } from '../../../utils/format'
4
- import { getProxiedImageUrl } from '../../../utils/image-proxy-stub'
5
4
 
6
5
  /** Minimal row shape the card renders. Both the hub dashboard entry and the
7
6
  * related-content hydrated row satisfy it structurally. */
8
- export interface WhatIShippedCardData {
9
- title?: string | null
10
- summary?: string | null
11
- status?: string | null
12
- featured_image?: string | null
13
- main_video_thumbnail?: string | null
7
+ export interface WhatIShippedCardData extends EmployeeEntryCardData {
14
8
  entry_month?: string | null
15
- author?: { full_name?: string | null; avatar_url?: string | null } | null
16
9
  }
17
10
 
18
11
  export interface WhatIShippedCardProps {
@@ -28,82 +21,26 @@ export interface WhatIShippedCardProps {
28
21
  className?: string
29
22
  }
30
23
 
31
- const STATUS_BADGE_CLASS: Record<string, string> = {
32
- published: 'bg-ods-success-secondary text-ods-success',
33
- draft: 'bg-ods-warning-secondary text-ods-warning',
34
- archived: 'bg-ods-border text-ods-text-secondary',
35
- }
36
-
37
24
  /**
38
- * THE single "What I Shipped" card. Wraps `AdminContentCard` with the canonical
39
- * mapping (cover = featured_image || main_video_thumbnail, OG placeholder
40
- * fallback, title, 140-char summary, status badge, author avatar+name +
41
- * reporting month). Used by BOTH the people-hub dashboard (with owner `actions`)
42
- * and the related-content rail (with `anchorProps` for click-through), so the
43
- * card is byte-identical everywhere — one component, one mapping, no drift.
25
+ * THE single "What I Shipped" card a thin binding over `EmployeeEntryCard`
26
+ * that maps the reporting month to the shared meta date. Used by BOTH the
27
+ * people-hub dashboard (with owner `actions`) and the related-content rail
28
+ * (with `anchorProps` for click-through), so the card is byte-identical
29
+ * everywhere one engine, one mapping, no drift.
44
30
  */
45
31
  export function WhatIShippedCard({ entry, placeholderUrl, actions, anchorProps, className }: WhatIShippedCardProps) {
46
- const month = formatEntryMonthUTC(entry.entry_month, 'short')
47
- const card = (
48
- <AdminContentCard
49
- imageUrl={entry.featured_image || entry.main_video_thumbnail}
32
+ return (
33
+ <EmployeeEntryCard
34
+ entry={entry}
35
+ dateLabel={formatEntryMonthUTC(entry.entry_month, 'short')}
50
36
  placeholderUrl={placeholderUrl}
51
- title={entry.title || 'Untitled entry'}
52
- summary={(entry.summary ?? '').slice(0, 140) || 'No description'}
53
- badges={
54
- entry.status ? (
55
- <span
56
- className={`px-2 py-1 rounded text-xs font-medium ${
57
- STATUS_BADGE_CLASS[entry.status] ?? 'bg-ods-card border border-ods-border text-ods-text-secondary'
58
- }`}
59
- >
60
- {entry.status}
61
- </span>
62
- ) : null
63
- }
64
- meta={
65
- <>
66
- <span className="flex items-center gap-2 min-w-0">
67
- {entry.author?.avatar_url ? (
68
- <img src={getProxiedImageUrl(entry.author.avatar_url) ?? entry.author.avatar_url} alt="" className="h-5 w-5 rounded-full object-cover shrink-0" />
69
- ) : null}
70
- <span className="truncate">{entry.author?.full_name ?? ''}</span>
71
- </span>
72
- {month ? <span>{month}</span> : null}
73
- </>
74
- }
75
37
  actions={actions}
38
+ anchorProps={anchorProps}
76
39
  className={className}
77
40
  />
78
41
  )
79
- return anchorProps ? (
80
- <a {...anchorProps} className="block h-full">
81
- {card}
82
- </a>
83
- ) : (
84
- card
85
- )
86
42
  }
87
43
 
88
- /** Loading skeleton matching WhatIShippedCard's AdminContentCard shape (3:2 cover
89
- * + title / summary / meta lines). Used by the related-content rail while a
90
- * group hydrates so there's no shape jump when the real card lands. */
91
- export function WhatIShippedCardSkeleton({ className }: { className?: string }) {
92
- // Same convention as BlogCardSkeleton et al.: animate-pulse on the container,
93
- // `bg-ods-bg` placeholder blocks, flex-grow body with an `mt-auto` avatar+name
94
- // row. Shape mirrors WhatIShippedCard's AdminContentCard (rounded-2xl, 3:2 cover).
95
- return (
96
- <div className={`group bg-ods-card border border-ods-border rounded-2xl overflow-hidden h-full flex flex-col animate-pulse ${className ?? ''}`}>
97
- <div className="aspect-[3/2] bg-ods-bg" />
98
- <div className="p-4 flex flex-col flex-grow space-y-3">
99
- <div className="h-5 w-3/4 bg-ods-bg rounded" />
100
- <div className="h-3 w-full bg-ods-bg/60 rounded" />
101
- <div className="h-3 w-4/5 bg-ods-bg/60 rounded" />
102
- <div className="mt-auto flex items-center gap-2">
103
- <div className="h-8 w-8 rounded-full bg-ods-bg" />
104
- <div className="h-3 w-24 bg-ods-bg/60 rounded" />
105
- </div>
106
- </div>
107
- </div>
108
- )
109
- }
44
+ /** Loading skeleton matching WhatIShippedCard's shape. Shared with every other
45
+ * employee-entry card so the rail's placeholder never diverges from the card. */
46
+ export const WhatIShippedCardSkeleton = EmployeeEntryCardSkeleton
@@ -21,6 +21,15 @@ export interface StatusFilterComponentProps {
21
21
  * dashboard expose every status while gating which ones a given viewer may
22
22
  * actually select (e.g. non-management can only pick `published`). */
23
23
  disabledValues?: string[];
24
+ /**
25
+ * Row label. Defaults to `Status`, so every existing call site is unchanged.
26
+ *
27
+ * Set it to reuse this row for any other single-select facet (Discipline,
28
+ * Level, …) instead of hand-rolling a look-alike: a duplicated row renders
29
+ * markup identical to this one, and React then pairs the copy against this
30
+ * component during hydration and reports a mismatch on the label text.
31
+ */
32
+ label?: string;
24
33
  }
25
34
 
26
35
  /**
@@ -35,7 +44,8 @@ export function StatusFilterComponent({
35
44
  showCount = false,
36
45
  count = 0,
37
46
  className = '',
38
- disabledValues = []
47
+ disabledValues = [],
48
+ label = 'Status'
39
49
  }: StatusFilterComponentProps) {
40
50
  // Filter out 'all' from options since we render it separately
41
51
  const filteredOptions = statusOptions.filter(option => option.value !== 'all');
@@ -45,7 +55,7 @@ export function StatusFilterComponent({
45
55
  <div className="flex items-center gap-2">
46
56
  <Filter className="h-4 w-4 text-ods-accent" />
47
57
  <span className="text-h5 text-ods-text-secondary">
48
- Status
58
+ {label}
49
59
  </span>
50
60
  </div>
51
61
 
@@ -65,6 +65,7 @@ import { decideNewTab } from '../chat/utils/decide-new-tab';
65
65
  // instantiates a QueryClient, so embedders need NO QueryClientProvider.
66
66
  import { BlogCard, BlogCardSkeleton } from '../chat/entity-cards/blog-card';
67
67
  import { WhatIShippedCard, WhatIShippedCardSkeleton } from '../chat/entity-cards/what-i-shipped-card';
68
+ import { HowIWorkCard, HowIWorkCardSkeleton } from '../chat/entity-cards/how-i-work-card';
68
69
  import { CaseStudyCard, CaseStudyCardSkeleton } from '../chat/entity-cards/case-study-card';
69
70
  import { CustomerInterviewCard, CustomerInterviewCardSkeleton } from '../chat/entity-cards/customer-interview-card';
70
71
  import { ProductReleaseCard, ProductReleaseCardSkeleton } from '../chat/entity-cards/product-release-card';
@@ -167,6 +168,9 @@ function renderSkeletonForType(
167
168
  case 'what_i_shipped':
168
169
  // Matches the WhatIShippedCard (AdminContentCard 3:2) shape.
169
170
  return <WhatIShippedCardSkeleton />;
171
+ case 'how_i_work':
172
+ // Same shared employee-entry shape as What I Shipped.
173
+ return <HowIWorkCardSkeleton />;
170
174
  case 'marketing_campaign':
171
175
  return adminCampaignCard ? <adminCampaignCard.Skeleton size={legacySize} /> : null;
172
176
  case 'roadmap_item':
@@ -296,6 +300,20 @@ function CardForType({
296
300
  }
297
301
  />
298
302
  );
303
+ case 'how_i_work':
304
+ // THE single How I Work card — same lib component the people-hub dashboard
305
+ // renders. Same `anchorProps` contract as What I Shipped above (a real
306
+ // href only; a fallback object would wrap the card in a dead <a>).
307
+ return (
308
+ <HowIWorkCard
309
+ entry={item}
310
+ placeholderUrl={placeholderUrl}
311
+ anchorProps={
312
+ linkProps ??
313
+ (href ? ({ href, ...anchorAttrs } as React.AnchorHTMLAttributes<HTMLAnchorElement>) : undefined)
314
+ }
315
+ />
316
+ );
299
317
  case 'marketing_campaign':
300
318
  return adminCampaignCard ? <adminCampaignCard.Card campaign={item} /> : null;
301
319
  case 'roadmap_item':
@@ -0,0 +1,95 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
+ import { EmployeeEntryCard, EmployeeEntryBadge } from '../components/chat/entity-cards/employee-entry-card'
3
+ import { ChatColumnDecorator, makeAnchorProps } from './__fixtures__/chat-card-decorator'
4
+ import { howIWorkEntry, whatIShippedEntry } from './__fixtures__/chat-cards'
5
+
6
+ const meta: Meta<typeof EmployeeEntryCard> = {
7
+ title: 'Chat/EntityCards/EmployeeEntryCard',
8
+ component: EmployeeEntryCard,
9
+ parameters: {
10
+ layout: 'fullscreen',
11
+ docs: {
12
+ description: {
13
+ component:
14
+ 'THE people-hub employee-entry engine. `WhatIShippedCard` and `HowIWorkCard` are thin bindings over it — they differ only in which date column they format and which extra badges they add — so the states exercised here are the states of every employee-entry card. Cover falls back featured_image → main_video_thumbnail → placeholderUrl.',
15
+ },
16
+ },
17
+ },
18
+ decorators: [(Story) => <ChatColumnDecorator><Story /></ChatColumnDecorator>],
19
+ }
20
+
21
+ export default meta
22
+ type Story = StoryObj<typeof meta>
23
+
24
+ export const Published: Story = {
25
+ args: {
26
+ entry: whatIShippedEntry,
27
+ dateLabel: 'Jul 2026',
28
+ },
29
+ }
30
+
31
+ export const Draft: Story = {
32
+ args: {
33
+ entry: { ...whatIShippedEntry, status: 'draft' },
34
+ dateLabel: 'Jul 2026',
35
+ },
36
+ }
37
+
38
+ export const Archived: Story = {
39
+ args: {
40
+ entry: { ...whatIShippedEntry, status: 'archived' },
41
+ dateLabel: 'Jul 2026',
42
+ },
43
+ }
44
+
45
+ export const WithExtraBadges: Story = {
46
+ args: {
47
+ entry: howIWorkEntry,
48
+ dateLabel: 'Aug 16, 2026',
49
+ extraBadges: (
50
+ <>
51
+ <EmployeeEntryBadge>Marketing</EmployeeEntryBadge>
52
+ <EmployeeEntryBadge>Intermediate</EmployeeEntryBadge>
53
+ </>
54
+ ),
55
+ },
56
+ }
57
+
58
+ /** Cover falls back to the video frame when there is no featured image. */
59
+ export const VideoThumbnailFallback: Story = {
60
+ args: {
61
+ entry: {
62
+ ...whatIShippedEntry,
63
+ featured_image: null,
64
+ main_video_thumbnail:
65
+ 'https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&h=675&fit=crop',
66
+ },
67
+ dateLabel: 'Jul 2026',
68
+ },
69
+ }
70
+
71
+ /** The auto-created-draft state: no title yet, no summary, no cover — the card
72
+ * must still read as a specific entity, not an error. */
73
+ export const EmptyDraft: Story = {
74
+ args: {
75
+ entry: {
76
+ title: null,
77
+ summary: null,
78
+ status: 'draft',
79
+ featured_image: null,
80
+ main_video_thumbnail: null,
81
+ author: whatIShippedEntry.author,
82
+ },
83
+ dateLabel: null,
84
+ untitledLabel: 'Untitled session',
85
+ },
86
+ }
87
+
88
+ /** Related-rail mode: the whole card is a link (never combined with actions). */
89
+ export const AsAnchor: Story = {
90
+ args: {
91
+ entry: whatIShippedEntry,
92
+ dateLabel: 'Jul 2026',
93
+ anchorProps: makeAnchorProps('/what-i-shipped/billing-flow'),
94
+ },
95
+ }
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
+ import { HowIWorkCard, HowIWorkCardSkeleton } from '../components/chat/entity-cards/how-i-work-card'
3
+ import { ChatColumnDecorator, makeAnchorProps } from './__fixtures__/chat-card-decorator'
4
+ import { howIWorkEntry } from './__fixtures__/chat-cards'
5
+
6
+ const meta: Meta<typeof HowIWorkCard> = {
7
+ title: 'Chat/EntityCards/HowIWorkCard',
8
+ component: HowIWorkCard,
9
+ parameters: {
10
+ layout: 'fullscreen',
11
+ docs: {
12
+ description: {
13
+ component:
14
+ 'Card for a `how_i_work` session — a thin binding over `EmployeeEntryCard` that maps `session_date` to the meta date and adds the discipline badge. Engine states (statuses, cover fallbacks, empty draft) are covered by the EmployeeEntryCard stories.',
15
+ },
16
+ },
17
+ },
18
+ decorators: [(Story) => <ChatColumnDecorator><Story /></ChatColumnDecorator>],
19
+ }
20
+
21
+ export default meta
22
+ type Story = StoryObj<typeof meta>
23
+
24
+ export const Default: Story = {
25
+ args: {
26
+ entry: howIWorkEntry,
27
+ },
28
+ }
29
+
30
+ /** Related-rail click-through — the whole card is one anchor. */
31
+ export const AsAnchor: Story = {
32
+ args: {
33
+ entry: howIWorkEntry,
34
+ anchorProps: makeAnchorProps('/how-i-work/' + 'competitive-research-claude'),
35
+ },
36
+ }
37
+
38
+ /** No discipline picked yet (fresh draft) — badge row shows status only. */
39
+ export const NoDiscipline: Story = {
40
+ args: {
41
+ entry: { ...howIWorkEntry, discipline: null, status: 'draft' },
42
+ },
43
+ }
44
+
45
+ export const Skeleton: StoryObj = {
46
+ render: () => <HowIWorkCardSkeleton />,
47
+ }
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
+ import { useState } from 'react'
3
+ import { StatusFilterComponent } from '../components/features/status-filter-component'
4
+
5
+ const meta: Meta<typeof StatusFilterComponent> = {
6
+ title: 'Features/StatusFilterComponent',
7
+ component: StatusFilterComponent,
8
+ parameters: {
9
+ layout: 'padded',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'THE single-select facet filter row. Renders its own "All" button and drops any option valued `all`, so callers pass real options only. The `label` prop lets one component serve every facet axis (Status, Discipline, Level) — hand-rolling a look-alike row produces markup identical to this one, and React then pairs the copy against this component during hydration and reports a text mismatch (the bug that motivated the prop).',
14
+ },
15
+ },
16
+ },
17
+ }
18
+
19
+ export default meta
20
+ type Story = StoryObj<typeof meta>
21
+
22
+ /** Default label. Interactive — pills toggle the selection. */
23
+ export const Status: Story = {
24
+ render: function StatusStory() {
25
+ const [status, setStatus] = useState('all')
26
+ return (
27
+ <StatusFilterComponent
28
+ selectedStatus={status}
29
+ onStatusChange={setStatus}
30
+ statusOptions={[
31
+ { value: 'draft', label: 'Draft (1)' },
32
+ { value: 'published', label: 'Published (3)' },
33
+ { value: 'archived', label: 'Archived (1)' },
34
+ ]}
35
+ />
36
+ )
37
+ },
38
+ }
39
+
40
+ /** Relabeled: the same row serving a data-derived Discipline facet, counts in
41
+ * the labels — how the people-hub How I Work dashboard uses it. */
42
+ export const RelabeledFacet: Story = {
43
+ render: function DisciplineStory() {
44
+ const [discipline, setDiscipline] = useState('all')
45
+ return (
46
+ <StatusFilterComponent
47
+ label="Discipline"
48
+ selectedStatus={discipline}
49
+ onStatusChange={setDiscipline}
50
+ statusOptions={[
51
+ { value: 'engineering', label: 'Engineering (4)' },
52
+ { value: 'marketing', label: 'Marketing (2)' },
53
+ { value: 'sales', label: 'Sales (0)' },
54
+ ]}
55
+ />
56
+ )
57
+ },
58
+ }
59
+
60
+ /** Viewer may see every option but select only one (people-hub "Everyone" view
61
+ * for non-management: pinned to Published, the rest visible but disabled). */
62
+ export const DisabledValues: Story = {
63
+ args: {
64
+ label: 'Status',
65
+ selectedStatus: 'published',
66
+ onStatusChange: () => {},
67
+ statusOptions: [
68
+ { value: 'draft', label: 'Draft' },
69
+ { value: 'published', label: 'Published' },
70
+ { value: 'archived', label: 'Archived' },
71
+ ],
72
+ disabledValues: ['all', 'draft', 'archived'],
73
+ },
74
+ }