@scalepad/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/.ai/rules/date-handling.md +39 -0
  2. package/.ai/rules/figma-design-system.md +372 -0
  3. package/.ai/rules/figma-lm-design-system-keys.md +680 -0
  4. package/.ai/rules/file-extensions.md +13 -0
  5. package/.ai/rules/modal-confirmation-mutation.md +56 -0
  6. package/.ai/rules/react-hooks.md +29 -0
  7. package/.ai/rules/styling.md +83 -0
  8. package/AGENTS.md +37 -0
  9. package/README.md +125 -0
  10. package/figma.config.json +9 -0
  11. package/package.json +127 -0
  12. package/scripts/install-ai-rules.mjs +136 -0
  13. package/src/ThemeProvider.tsx +57 -0
  14. package/src/charts.ts +32 -0
  15. package/src/components/ActionCard/ActionCard.css.ts +60 -0
  16. package/src/components/ActionCard/ActionCard.tsx +154 -0
  17. package/src/components/ActionCard/index.ts +2 -0
  18. package/src/components/Anchor/Anchor.tsx +47 -0
  19. package/src/components/Anchor/index.ts +2 -0
  20. package/src/components/AppliedFiltersManagerBar/AppliedFiltersManagerBar.tsx +105 -0
  21. package/src/components/AppliedFiltersManagerBar/FilterBadge.css.ts +23 -0
  22. package/src/components/AppliedFiltersManagerBar/FilterBadge.tsx +50 -0
  23. package/src/components/AppliedFiltersManagerBar/index.ts +5 -0
  24. package/src/components/Badge/Badge.css.ts +72 -0
  25. package/src/components/Badge/Badge.figma.tsx +43 -0
  26. package/src/components/Badge/Badge.tsx +159 -0
  27. package/src/components/Badge/index.ts +2 -0
  28. package/src/components/BreadCrumb/BreadCrumb.tsx +62 -0
  29. package/src/components/BreadCrumb/index.ts +2 -0
  30. package/src/components/BulkActionBar/BulkActionBar.css.ts +26 -0
  31. package/src/components/BulkActionBar/BulkActionBar.tsx +164 -0
  32. package/src/components/BulkActionBar/index.ts +2 -0
  33. package/src/components/Button/Button.css.ts +272 -0
  34. package/src/components/Button/Button.figma.tsx +74 -0
  35. package/src/components/Button/Button.tsx +84 -0
  36. package/src/components/Button/index.ts +2 -0
  37. package/src/components/Charts/ChartTooltip.figma.tsx +33 -0
  38. package/src/components/Charts/ChartTooltip.tsx +101 -0
  39. package/src/components/Charts/MiniBarSparkline.tsx +75 -0
  40. package/src/components/Charts/StackedPatternBarChart.tsx +494 -0
  41. package/src/components/Charts/TrendAreaChart.css.ts +23 -0
  42. package/src/components/Charts/TrendAreaChart.tsx +210 -0
  43. package/src/components/Charts/index.ts +12 -0
  44. package/src/components/CodePanel/CodePanel.css.ts +113 -0
  45. package/src/components/CodePanel/CodePanel.tsx +121 -0
  46. package/src/components/CodePanel/index.ts +2 -0
  47. package/src/components/CommentComposer/CommentComposer.css.ts +60 -0
  48. package/src/components/CommentComposer/CommentComposer.tsx +181 -0
  49. package/src/components/CommentComposer/index.ts +2 -0
  50. package/src/components/ConfirmationModal/ConfirmationModal.tsx +149 -0
  51. package/src/components/ConfirmationModal/index.ts +2 -0
  52. package/src/components/ConfirmationTooltip/ConfirmationTooltip.tsx +132 -0
  53. package/src/components/ConfirmationTooltip/index.ts +2 -0
  54. package/src/components/DataDialog.figma.tsx +33 -0
  55. package/src/components/DataDialog.tsx +46 -0
  56. package/src/components/DataTable/DataTable.tsx +1042 -0
  57. package/src/components/DataTable/RowExpandToggle.tsx +105 -0
  58. package/src/components/DataTable/RowGroupHeader.tsx +190 -0
  59. package/src/components/DataTable/createActionsColumn.tsx +86 -0
  60. package/src/components/DataTable/index.ts +25 -0
  61. package/src/components/DatePicker/CustomRangePicker.tsx +59 -0
  62. package/src/components/DatePicker/DateInput.tsx +329 -0
  63. package/src/components/DatePicker/DateNavigator.tsx +486 -0
  64. package/src/components/DatePicker/DatePicker.tsx +242 -0
  65. package/src/components/DatePicker/MonthlyRangePicker.tsx +231 -0
  66. package/src/components/DatePicker/QuarterlyRangePicker.tsx +224 -0
  67. package/src/components/DatePicker/QuickPicksSidebar.tsx +242 -0
  68. package/src/components/DatePicker/YearlyRangePicker.tsx +171 -0
  69. package/src/components/DatePicker/index.ts +7 -0
  70. package/src/components/DatePicker/types.ts +12 -0
  71. package/src/components/DesignSystemPrimitives/FluidGrid.tsx +44 -0
  72. package/src/components/DesignSystemPrimitives/InteractivePrimitives.tsx +177 -0
  73. package/src/components/DesignSystemPrimitives/LayoutPrimitives.tsx +220 -0
  74. package/src/components/DesignSystemPrimitives/LayoutPrimitives.types.tsx +15 -0
  75. package/src/components/DesignSystemPrimitives/SurfacePrimitives.tsx +46 -0
  76. package/src/components/DesignSystemPrimitives/index.ts +55 -0
  77. package/src/components/Details/Details.css.ts +74 -0
  78. package/src/components/Details/Details.tsx +140 -0
  79. package/src/components/Details/index.ts +2 -0
  80. package/src/components/DownloadCard/DownloadCard.css.ts +22 -0
  81. package/src/components/DownloadCard/DownloadCard.tsx +63 -0
  82. package/src/components/DownloadCard/index.ts +2 -0
  83. package/src/components/Drawer/Drawer.css.ts +32 -0
  84. package/src/components/Drawer/Drawer.tsx +236 -0
  85. package/src/components/Drawer/hooks/useDetailDrawer.ts +61 -0
  86. package/src/components/Drawer/hooks/useDetailDrawerNavigation.ts +125 -0
  87. package/src/components/Drawer/hooks/useDetailDrawerNavigationContext.ts +66 -0
  88. package/src/components/EditableRichText/EditableRichText.css.ts +72 -0
  89. package/src/components/EditableRichText/EditableRichText.tsx +324 -0
  90. package/src/components/EditableRichText/index.ts +2 -0
  91. package/src/components/EditableSelect/EditableSelect.css.ts +62 -0
  92. package/src/components/EditableSelect/EditableSelect.tsx +224 -0
  93. package/src/components/EditableSelect/index.ts +2 -0
  94. package/src/components/EditableText/EditableText.tsx +377 -0
  95. package/src/components/EditableText/index.ts +2 -0
  96. package/src/components/EmptyState/EmptyState.figma.tsx +33 -0
  97. package/src/components/EmptyState/EmptyState.tsx +230 -0
  98. package/src/components/EmptyState/index.ts +2 -0
  99. package/src/components/ErrorBoundary.tsx +135 -0
  100. package/src/components/ErrorState/ErrorState.tsx +197 -0
  101. package/src/components/ErrorState/index.ts +2 -0
  102. package/src/components/FeatureCard.tsx +42 -0
  103. package/src/components/FilterMenu/FilterMenu.figma.tsx +30 -0
  104. package/src/components/FilterMenu/FilterMenu.tsx +198 -0
  105. package/src/components/FilterMenu/FilterSubMenuTypes/BooleanFilterSubmenu.tsx +46 -0
  106. package/src/components/FilterMenu/FilterSubMenuTypes/SearchableFilterSubmenu.tsx +239 -0
  107. package/src/components/FilterMenu/FilterSubMenuTypes/index.ts +8 -0
  108. package/src/components/FilterMenu/defaultFilterSchemas.ts +63 -0
  109. package/src/components/FilterMenu/helpers.ts +115 -0
  110. package/src/components/FilterMenu/index.ts +35 -0
  111. package/src/components/FilterMenu/types.ts +101 -0
  112. package/src/components/IconButton/IconButton.css.ts +272 -0
  113. package/src/components/IconButton/IconButton.figma.tsx +47 -0
  114. package/src/components/IconButton/IconButton.tsx +72 -0
  115. package/src/components/IconButton/README.md +230 -0
  116. package/src/components/IconButton/index.ts +2 -0
  117. package/src/components/InfiniteScrollSentinel.tsx +86 -0
  118. package/src/components/InfiniteScrollTrigger.tsx +78 -0
  119. package/src/components/InfoCard.figma.tsx +47 -0
  120. package/src/components/InfoCard.tsx +216 -0
  121. package/src/components/KbdHint/KbdHint.tsx +23 -0
  122. package/src/components/KbdHint/index.ts +2 -0
  123. package/src/components/LabeledField/LabeledField.tsx +21 -0
  124. package/src/components/LabeledField/index.ts +2 -0
  125. package/src/components/LookupSelect/LookupSelect.css.ts +149 -0
  126. package/src/components/LookupSelect/LookupSelect.tsx +325 -0
  127. package/src/components/LookupSelect/index.ts +2 -0
  128. package/src/components/Menu/Menu.css.ts +89 -0
  129. package/src/components/Menu/Menu.tsx +105 -0
  130. package/src/components/Menu/index.ts +2 -0
  131. package/src/components/MessageBox/MessageBox.tsx +168 -0
  132. package/src/components/MessageBox/index.ts +2 -0
  133. package/src/components/MetricDisplay/MetricDisplay.tsx +55 -0
  134. package/src/components/MetricDisplay/index.ts +1 -0
  135. package/src/components/MultiSelect/MultiSelect.tsx +278 -0
  136. package/src/components/MultiSelect/index.ts +2 -0
  137. package/src/components/Notifications/Notifications.tsx +12 -0
  138. package/src/components/Notifications/README.md +93 -0
  139. package/src/components/Notifications/index.ts +4 -0
  140. package/src/components/Notifications/showToast.tsx +100 -0
  141. package/src/components/PropertyRow/PropertyRow.tsx +96 -0
  142. package/src/components/PropertyRow/index.ts +2 -0
  143. package/src/components/RadioTile/RadioTile.tsx +253 -0
  144. package/src/components/RadioTile/index.ts +2 -0
  145. package/src/components/RichText/FormattingToolbar.css.ts +69 -0
  146. package/src/components/RichText/FormattingToolbar.tsx +112 -0
  147. package/src/components/RichText/RichTextInline.css.ts +54 -0
  148. package/src/components/RichText/RichTextInline.tsx +318 -0
  149. package/src/components/RichText/formattingCommands.ts +181 -0
  150. package/src/components/RichText/formattingTypes.ts +34 -0
  151. package/src/components/RichText/index.ts +49 -0
  152. package/src/components/RichText/richTextExtensions.ts +111 -0
  153. package/src/components/RichText/richTextHelpers.ts +65 -0
  154. package/src/components/RichText/richTextImage.ts +253 -0
  155. package/src/components/RichText/richTextImageHandlers.ts +244 -0
  156. package/src/components/RichText/richTextProse.css.ts +261 -0
  157. package/src/components/RichTextEditor/RichTextEditor.css.ts +82 -0
  158. package/src/components/RichTextEditor/RichTextEditor.tsx +204 -0
  159. package/src/components/RichTextEditor/index.ts +2 -0
  160. package/src/components/RichTextView/RichTextView.css.ts +11 -0
  161. package/src/components/RichTextView/RichTextView.tsx +114 -0
  162. package/src/components/RichTextView/index.ts +2 -0
  163. package/src/components/Schedule/Schedule.tsx +35 -0
  164. package/src/components/SchedulePicker/SchedulePicker.css.ts +42 -0
  165. package/src/components/SchedulePicker/SchedulePicker.tsx +130 -0
  166. package/src/components/SchedulePicker/index.ts +2 -0
  167. package/src/components/SearchableList/types.ts +30 -0
  168. package/src/components/SearchableSubMenu/SearchableSubMenu.css.ts +25 -0
  169. package/src/components/SearchableSubMenu/SearchableSubMenu.tsx +139 -0
  170. package/src/components/SearchableSubMenu/index.ts +2 -0
  171. package/src/components/Select/README.md +114 -0
  172. package/src/components/Select/Select.css.ts +110 -0
  173. package/src/components/Select/Select.tsx +133 -0
  174. package/src/components/Select/index.ts +2 -0
  175. package/src/components/SelectCreatable/SelectCreatable.css.ts +16 -0
  176. package/src/components/SelectCreatable/SelectCreatable.tsx +203 -0
  177. package/src/components/SelectCreatable/index.ts +2 -0
  178. package/src/components/SettingsCard/SettingsCard.tsx +98 -0
  179. package/src/components/SettingsCard/index.ts +2 -0
  180. package/src/components/Sidebar/Sidebar.css.ts +91 -0
  181. package/src/components/Sidebar/Sidebar.tsx +129 -0
  182. package/src/components/Sidebar/index.ts +5 -0
  183. package/src/components/SimpleList/SimpleList.css.ts +12 -0
  184. package/src/components/SimpleList/SimpleList.tsx +44 -0
  185. package/src/components/SimpleList/index.ts +2 -0
  186. package/src/components/SimpleTable/SimpleTable.tsx +296 -0
  187. package/src/components/SimpleTable/index.ts +2 -0
  188. package/src/components/SlashRichTextEditor/SelectionBubbleMenu.css.ts +62 -0
  189. package/src/components/SlashRichTextEditor/SelectionBubbleMenu.tsx +85 -0
  190. package/src/components/SlashRichTextEditor/SlashCommandMenu.css.ts +124 -0
  191. package/src/components/SlashRichTextEditor/SlashCommandMenu.tsx +168 -0
  192. package/src/components/SlashRichTextEditor/SlashRichTextEditor.css.ts +81 -0
  193. package/src/components/SlashRichTextEditor/SlashRichTextEditor.tsx +538 -0
  194. package/src/components/SlashRichTextEditor/SlashSuggestionExtension.ts +48 -0
  195. package/src/components/SlashRichTextEditor/index.ts +13 -0
  196. package/src/components/SlashRichTextEditor/types.ts +48 -0
  197. package/src/components/StatCard/StatCard.css.ts +70 -0
  198. package/src/components/StatCard/StatCard.tsx +201 -0
  199. package/src/components/StatCard/index.ts +1 -0
  200. package/src/components/StatusBadge/StatusBadge.tsx +70 -0
  201. package/src/components/StatusBadge/index.ts +2 -0
  202. package/src/components/StatusIndicator/StatusIndicator.tsx +67 -0
  203. package/src/components/StatusIndicator/index.ts +6 -0
  204. package/src/components/SubNavigation/SubNavigation.css.ts +72 -0
  205. package/src/components/SubNavigation/SubNavigation.tsx +104 -0
  206. package/src/components/SubNavigation/index.ts +2 -0
  207. package/src/components/SuspenseLoader.tsx +22 -0
  208. package/src/components/Table/SortableColumnHeader.tsx +99 -0
  209. package/src/components/Table/TableSkeletonRows.figma.tsx +22 -0
  210. package/src/components/Table/TableSkeletonRows.tsx +113 -0
  211. package/src/components/Table/index.ts +9 -0
  212. package/src/components/TableActionsMenu.tsx +58 -0
  213. package/src/components/TableCard.tsx +29 -0
  214. package/src/components/TableContainer/TableContainer.tsx +86 -0
  215. package/src/components/TableContainer/index.ts +2 -0
  216. package/src/components/TableControlBar/TableControlBar.tsx +156 -0
  217. package/src/components/TableControlBar/TableSelectionButton.tsx +57 -0
  218. package/src/components/TableControlBar/index.ts +13 -0
  219. package/src/components/TableControlBar/useTableControlBar.tsx +314 -0
  220. package/src/components/TableSelection/TableSelection.tsx +43 -0
  221. package/src/components/TableSelection/index.ts +5 -0
  222. package/src/components/Tabs/README.md +76 -0
  223. package/src/components/Tabs/Tabs.css.ts +54 -0
  224. package/src/components/Tabs/Tabs.figma.tsx +47 -0
  225. package/src/components/Tabs/Tabs.tsx +96 -0
  226. package/src/components/Tabs/index.ts +8 -0
  227. package/src/components/TextInput/README.md +98 -0
  228. package/src/components/TextInput/SearchTextInput.figma.tsx +22 -0
  229. package/src/components/TextInput/SearchTextInput.tsx +150 -0
  230. package/src/components/TextInput/TextInput.figma.tsx +44 -0
  231. package/src/components/TextInput/TextInput.tsx +42 -0
  232. package/src/components/TextInput/index.ts +4 -0
  233. package/src/components/ThemeSwitcher.figma.tsx +28 -0
  234. package/src/components/ThemeSwitcher.tsx +69 -0
  235. package/src/components/TrendBadge/TrendBadge.tsx +76 -0
  236. package/src/components/TrendBadge/index.ts +2 -0
  237. package/src/components/TruncatedText.tsx +115 -0
  238. package/src/components/Typography/Text.tsx +74 -0
  239. package/src/components/Typography/Title.tsx +100 -0
  240. package/src/components/Typography/index.ts +4 -0
  241. package/src/geist-fonts.ts +48 -0
  242. package/src/hooks/index.ts +31 -0
  243. package/src/hooks/useFilters.ts +152 -0
  244. package/src/hooks/useInfiniteScroll.ts +62 -0
  245. package/src/hooks/usePlatform.ts +33 -0
  246. package/src/hooks/useServerTable.ts +495 -0
  247. package/src/hooks/useTableSelection.ts +102 -0
  248. package/src/hooks/useTableSort.ts +259 -0
  249. package/src/index.ts +483 -0
  250. package/src/mantine.ts +25 -0
  251. package/src/theme/mantineVars.ts +12 -0
  252. package/src/theme/themeContract.css.ts +131 -0
  253. package/src/theme/themeVars.ts +31 -0
  254. package/src/theme.ts +168 -0
  255. package/src/tokens/color-types.ts +107 -0
  256. package/src/tokens/colors.ts +243 -0
  257. package/src/tokens/index.ts +14 -0
  258. package/src/tokens/radius.ts +17 -0
  259. package/src/tokens/semantic-colors.ts +224 -0
  260. package/src/tokens/semantic-tokens-css.ts +53 -0
  261. package/src/tokens/shadows.ts +11 -0
  262. package/src/tokens/spacing.ts +20 -0
  263. package/src/tokens/text-styles.ts +179 -0
  264. package/src/tokens/typography.ts +40 -0
  265. package/src/tokens/zIndex.ts +27 -0
  266. package/src/types/mantine-theme.d.ts +17 -0
  267. package/src/types/tanstack-table.d.ts +22 -0
  268. package/src/utils/avatar.ts +150 -0
  269. package/src/utils/chartHelpers.ts +53 -0
  270. package/src/utils/color-props.ts +77 -0
  271. package/src/utils/createDesignComponent.tsx +104 -0
  272. package/src/utils/nestFlatRows.ts +111 -0
  273. package/src/utils/withStaticComponents.ts +6 -0
@@ -0,0 +1,261 @@
1
+ import { globalStyle, style } from '@vanilla-extract/css';
2
+
3
+ import { tokens } from '../../theme/themeContract.css';
4
+
5
+ /**
6
+ * Shared prose styles for Tiptap / ProseMirror content. Applied by both
7
+ * `SlashRichTextEditor` (edit mode) and `RichTextView` (read-only mode)
8
+ * so the writing surface and the rendered view look pixel-identical.
9
+ *
10
+ * Tiptap wraps editable content in a `.ProseMirror` element and the
11
+ * read-only view renders the same HTML directly under the wrapper, so
12
+ * each rule below targets either path:
13
+ *
14
+ * `${prose} h1` matches both the editor's
15
+ * `<div class=prose><div class=ProseMirror><h1>` and the view's
16
+ * `<div class=prose><h1>`.
17
+ */
18
+ export const prose = style({
19
+ color: tokens.color.text.default,
20
+ fontSize: '0.875rem',
21
+ lineHeight: 1.5,
22
+ });
23
+
24
+ globalStyle(`${prose} [contenteditable="true"]`, {
25
+ outline: 'none',
26
+ });
27
+
28
+ globalStyle(`${prose} .ProseMirror`, {
29
+ outline: 'none',
30
+ });
31
+
32
+ /* Only the editable surface needs a tall click target — the read-only
33
+ * `RichTextView` shares the `.ProseMirror` wrapper but should hug its
34
+ * content so list rows and detail headers don't gain ~4rem of empty
35
+ * space under short rich-text descriptions. */
36
+ globalStyle(`${prose} .ProseMirror[contenteditable="true"]`, {
37
+ minHeight: '4rem',
38
+ });
39
+
40
+ /* ──────────────────────────────────────────────────────────────────
41
+ * Block-level elements
42
+ * ────────────────────────────────────────────────────────────────── */
43
+
44
+ globalStyle(`${prose} p`, {
45
+ margin: 0,
46
+ lineHeight: 1.5,
47
+ });
48
+
49
+ globalStyle(`${prose} p + p`, {
50
+ marginTop: tokens.spacing['2xs'],
51
+ });
52
+
53
+ globalStyle(`${prose} h1, ${prose} h2, ${prose} h3`, {
54
+ margin: 0,
55
+ fontWeight: 700,
56
+ lineHeight: 1.3,
57
+ });
58
+
59
+ globalStyle(`${prose} h1`, {
60
+ fontSize: '1.25rem',
61
+ });
62
+
63
+ globalStyle(`${prose} h2`, {
64
+ fontSize: '1.125rem',
65
+ });
66
+
67
+ globalStyle(`${prose} h3`, {
68
+ fontSize: '1rem',
69
+ });
70
+
71
+ /* Headings that follow other content get a small top margin so they
72
+ * read as a new section. Matches the editor's writing rhythm. */
73
+ globalStyle(`${prose} * + h1, ${prose} * + h2, ${prose} * + h3`, {
74
+ marginTop: tokens.spacing.xs,
75
+ });
76
+
77
+ /* ──────────────────────────────────────────────────────────────────
78
+ * Lists — `list-style-type` is required because Mantine's CSS reset
79
+ * strips it. Bullets/numbers must be explicit so they survive.
80
+ * ────────────────────────────────────────────────────────────────── */
81
+
82
+ globalStyle(`${prose} ul, ${prose} ol`, {
83
+ margin: 0,
84
+ paddingLeft: tokens.spacing.lg,
85
+ });
86
+
87
+ globalStyle(`${prose} ul`, {
88
+ listStyleType: 'disc',
89
+ });
90
+
91
+ globalStyle(`${prose} ol`, {
92
+ listStyleType: 'decimal',
93
+ });
94
+
95
+ globalStyle(`${prose} li`, {
96
+ margin: 0,
97
+ paddingLeft: tokens.spacing['3xs'],
98
+ });
99
+
100
+ globalStyle(`${prose} li + li`, {
101
+ marginTop: tokens.spacing['3xs'],
102
+ });
103
+
104
+ /* Tiptap wraps each list item's content in a `<p>` — collapse that
105
+ * paragraph's margin so list items don't gain a second baseline. */
106
+ globalStyle(`${prose} li > p`, {
107
+ margin: 0,
108
+ });
109
+
110
+ /* Lists adjacent to body content need a touch of breathing room so
111
+ * the bullets don't bump up against a paragraph above them. */
112
+ globalStyle(
113
+ `${prose} p + ul, ${prose} p + ol, ${prose} ul + p, ${prose} ol + p`,
114
+ {
115
+ marginTop: tokens.spacing['2xs'],
116
+ },
117
+ );
118
+
119
+ /* ──────────────────────────────────────────────────────────────────
120
+ * Blockquote, code, links
121
+ * ────────────────────────────────────────────────────────────────── */
122
+
123
+ globalStyle(`${prose} blockquote`, {
124
+ margin: 0,
125
+ paddingLeft: tokens.spacing.sm,
126
+ borderLeft: `2px solid ${tokens.color.stroke.subduedDefault}`,
127
+ color: tokens.color.text.subduedStrong,
128
+ });
129
+
130
+ globalStyle(`${prose} blockquote > p`, {
131
+ margin: 0,
132
+ });
133
+
134
+ globalStyle(`${prose} code`, {
135
+ fontFamily: 'var(--font-family-monospace)',
136
+ background: tokens.color.background.subduedLight,
137
+ padding: '0 4px',
138
+ borderRadius: tokens.radius.xs,
139
+ fontSize: '0.95em',
140
+ });
141
+
142
+ globalStyle(`${prose} pre`, {
143
+ margin: 0,
144
+ padding: tokens.spacing.sm,
145
+ borderRadius: tokens.radius.sm,
146
+ background: tokens.color.background.subduedLight,
147
+ overflow: 'auto',
148
+ });
149
+
150
+ globalStyle(`${prose} pre > code`, {
151
+ background: 'transparent',
152
+ padding: 0,
153
+ borderRadius: 0,
154
+ fontSize: '0.95em',
155
+ });
156
+
157
+ globalStyle(`${prose} a`, {
158
+ color: tokens.color.text.primaryDefault,
159
+ textDecoration: 'underline',
160
+ });
161
+
162
+ globalStyle(`${prose} hr`, {
163
+ margin: `${tokens.spacing.sm} 0`,
164
+ border: 'none',
165
+ borderTop: `1px solid ${tokens.color.stroke.subduedDefault}`,
166
+ });
167
+
168
+ /* ──────────────────────────────────────────────────────────────────
169
+ * Inline marks — these have browser defaults, but Mantine's reset can
170
+ * normalise them away. Re-state them so bold/italic/underline always
171
+ * survive the document round-trip.
172
+ * ────────────────────────────────────────────────────────────────── */
173
+
174
+ globalStyle(`${prose} strong, ${prose} b`, {
175
+ fontWeight: 700,
176
+ });
177
+
178
+ globalStyle(`${prose} em, ${prose} i`, {
179
+ fontStyle: 'italic',
180
+ });
181
+
182
+ globalStyle(`${prose} u`, {
183
+ textDecoration: 'underline',
184
+ });
185
+
186
+ globalStyle(`${prose} s, ${prose} del`, {
187
+ textDecoration: 'line-through',
188
+ });
189
+
190
+ /* ──────────────────────────────────────────────────────────────────
191
+ * Placeholder — Tiptap's `@tiptap/extension-placeholder` toggles
192
+ * `is-editor-empty` on the first child when the doc has no content.
193
+ * Editor-only behaviour, but lives here so the editor can drop its
194
+ * own duplicated copy.
195
+ * ────────────────────────────────────────────────────────────────── */
196
+
197
+ globalStyle(`${prose} .ProseMirror p.is-editor-empty:first-child::before`, {
198
+ content: 'attr(data-placeholder)',
199
+ color: tokens.color.text.subduedDefault,
200
+ pointerEvents: 'none',
201
+ height: 0,
202
+ float: 'left',
203
+ });
204
+
205
+ /* ──────────────────────────────────────────────────────────────────
206
+ * Read-only collapse — when the surface isn't editable (i.e.
207
+ * `RichTextView`), collapse runs of empty paragraphs so legacy docs
208
+ * (Beast's plain-text-to-JSON converter emits one `<p></p>` per
209
+ * `\n\n`) read as the user-intended content rather than as a wall of
210
+ * whitespace. The editor leaves them alone so the cursor still has a
211
+ * line to live on while typing.
212
+ * ────────────────────────────────────────────────────────────────── */
213
+
214
+ globalStyle(`${prose} .ProseMirror[contenteditable="false"] p:empty`, {
215
+ display: 'none',
216
+ });
217
+
218
+ /* `<p><br></p>` is the other empty-paragraph shape Tiptap can emit
219
+ * (used as a hard-break placeholder). Drop those too in read-only
220
+ * mode so the trailing line-height doesn't bloat the layout. */
221
+ globalStyle(
222
+ `${prose} .ProseMirror[contenteditable="false"] p:has(> br:only-child)`,
223
+ {
224
+ display: 'none',
225
+ },
226
+ );
227
+
228
+ /* ──────────────────────────────────────────────────────────────────
229
+ * Image nodes — uploaded images sit in the doc with an empty `src`
230
+ * and a `data-rich-text-file-id` attribute until the lazy loader
231
+ * resolves them (see `useRichTextImageLoader`). The states below
232
+ * keep the placeholder readable while loading and give the layout a
233
+ * stable footprint so other content doesn't reflow as images come
234
+ * in.
235
+ * ────────────────────────────────────────────────────────────────── */
236
+
237
+ globalStyle(`${prose} img.rich-text-image`, {
238
+ maxWidth: '100%',
239
+ height: 'auto',
240
+ borderRadius: tokens.radius.md,
241
+ });
242
+
243
+ globalStyle(
244
+ `${prose} img.rich-text-image[data-rich-text-image-state="loading"], ${prose} img.rich-text-image[data-rich-text-image-state="idle"]`,
245
+ {
246
+ minHeight: '4rem',
247
+ width: '100%',
248
+ backgroundColor: tokens.color.background.subduedLight,
249
+ border: `1px dashed ${tokens.color.stroke.subduedDefault}`,
250
+ },
251
+ );
252
+
253
+ globalStyle(
254
+ `${prose} img.rich-text-image[data-rich-text-image-state="error"]`,
255
+ {
256
+ minHeight: '4rem',
257
+ width: '100%',
258
+ backgroundColor: tokens.color.background.dangerLight,
259
+ border: `1px dashed ${tokens.color.stroke.dangerDefault}`,
260
+ },
261
+ );
@@ -0,0 +1,82 @@
1
+ import { globalStyle, style } from '@vanilla-extract/css';
2
+
3
+ import { tokens } from '../../theme/themeContract.css';
4
+
5
+ // Prose styles (<p>, <ul>, <ol>, <a>) are in ../RichText/richTextProse.css.ts
6
+ // so they can be shared by RichTextView and EditableRichText.
7
+
8
+ export const root = style({
9
+ border: `1px solid ${tokens.color.stroke.default}`,
10
+ borderRadius: tokens.radius.lg,
11
+ background: tokens.color.background.input,
12
+ overflow: 'hidden',
13
+ });
14
+
15
+ export const toolbar = style({
16
+ display: 'flex',
17
+ alignItems: 'center',
18
+ gap: tokens.spacing.xs,
19
+ padding: tokens.spacing.sm,
20
+ borderBottom: `1px solid ${tokens.color.stroke.subduedDefault}`,
21
+ background: tokens.color.background.subduedUltralight,
22
+ flexWrap: 'wrap',
23
+ });
24
+
25
+ export const editor = style({
26
+ minHeight: '9.5rem',
27
+ padding: `${tokens.spacing.sm} ${tokens.spacing.md}`,
28
+ color: tokens.color.text.default,
29
+ });
30
+
31
+ export const actions = style({
32
+ display: 'flex',
33
+ justifyContent: 'space-between',
34
+ alignItems: 'flex-end',
35
+ gap: tokens.spacing.sm,
36
+ padding: tokens.spacing.sm,
37
+ borderTop: `1px solid ${tokens.color.stroke.subduedDefault}`,
38
+ background: tokens.color.background.default,
39
+ flexWrap: 'wrap',
40
+ });
41
+
42
+ export const actionMeta = style({
43
+ display: 'flex',
44
+ flexDirection: 'column',
45
+ gap: tokens.spacing.xs,
46
+ minWidth: 0,
47
+ });
48
+
49
+ export const keyboardHint = style({
50
+ color: tokens.color.text.subduedDefault,
51
+ });
52
+
53
+ export const errorRow = style({
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ gap: tokens.spacing.sm,
57
+ flexWrap: 'wrap',
58
+ });
59
+
60
+ export const actionButtons = style({
61
+ display: 'flex',
62
+ alignItems: 'center',
63
+ gap: tokens.spacing.xs,
64
+ });
65
+
66
+ export const srOnlyTextarea = style({
67
+ position: 'absolute',
68
+ width: 1,
69
+ height: 1,
70
+ padding: 0,
71
+ margin: -1,
72
+ overflow: 'hidden',
73
+ clip: 'rect(0, 0, 0, 0)',
74
+ whiteSpace: 'nowrap',
75
+ border: 0,
76
+ });
77
+
78
+ // Keep the modal-editor's taller empty-state minimum so single-line edits
79
+ // still present a large, obvious input target.
80
+ globalStyle(`${editor} [contenteditable="true"]`, {
81
+ minHeight: '7rem',
82
+ });
@@ -0,0 +1,204 @@
1
+ import { useEffect, useMemo, type ChangeEvent } from 'react';
2
+
3
+ import { EditorContent, useEditor, type JSONContent } from '@tiptap/react';
4
+ import { X } from 'lucide-react';
5
+
6
+ import { Button } from '../Button';
7
+ import { IconButton } from '../IconButton';
8
+ import { STANDARD_FORMATTING_COMMANDS } from '../RichText/formattingCommands';
9
+ import { FormattingToolbar } from '../RichText/FormattingToolbar';
10
+ import { getRichTextExtensions } from '../RichText/richTextExtensions';
11
+ import {
12
+ docsEqual,
13
+ normalizeValue,
14
+ plainTextFromDoc,
15
+ richTextDocFromPlainText,
16
+ } from '../RichText/richTextHelpers';
17
+ import {
18
+ installRichTextImageHandlers,
19
+ useRichTextImageLoader,
20
+ type RichTextImageEditorConfig,
21
+ } from '../RichText/richTextImageHandlers';
22
+ import * as proseStyles from '../RichText/richTextProse.css';
23
+ import { Text } from '../Typography';
24
+ import * as styles from './RichTextEditor.css';
25
+
26
+ export interface RichTextEditorProps {
27
+ value: JSONContent | null;
28
+ onChange: (value: JSONContent, plainText: string) => void;
29
+ onSubmit: () => void;
30
+ onCancel: () => void;
31
+ label: string;
32
+ submitLabel?: string;
33
+ submitDisabled?: boolean;
34
+ cancelDisabled?: boolean;
35
+ submitLoading?: boolean;
36
+ submitError?: string | null;
37
+ onSubmitErrorRetry?: () => void;
38
+ submitErrorRetryDisabled?: boolean;
39
+ disabled?: boolean;
40
+ /**
41
+ * When provided, enables inline image upload via paste / drop. Pass
42
+ * `undefined` (the default) on surfaces where images shouldn't be
43
+ * allowed — the Image extension is omitted from the schema entirely so
44
+ * users can't even paste one. See
45
+ * {@link RichTextImageEditorConfig} for the full callback surface.
46
+ */
47
+ image?: RichTextImageEditorConfig;
48
+ }
49
+
50
+ /**
51
+ * Form-style rich-text editor with explicit Save / Cancel buttons,
52
+ * Cmd/Ctrl+Enter submit, Esc cancel, and a built-in submit-error row with
53
+ * retry. Built on the canonical `RichText` foundation (shared extensions,
54
+ * shared `FormattingToolbar`) so a doc authored here round-trips through
55
+ * `RichTextView` without losing marks.
56
+ */
57
+ export function RichTextEditor({
58
+ value,
59
+ onChange,
60
+ onSubmit,
61
+ onCancel,
62
+ label,
63
+ submitLabel = 'Save',
64
+ submitDisabled = false,
65
+ cancelDisabled = false,
66
+ submitLoading = false,
67
+ submitError = null,
68
+ onSubmitErrorRetry,
69
+ submitErrorRetryDisabled = false,
70
+ disabled = false,
71
+ image,
72
+ }: RichTextEditorProps) {
73
+ const normalizedValue = useMemo(() => normalizeValue(value), [value]);
74
+ const enableImages = Boolean(image);
75
+ const editor = useEditor(
76
+ {
77
+ immediatelyRender: false,
78
+ editable: !disabled,
79
+ extensions: getRichTextExtensions({ enableImages }),
80
+ content: normalizedValue,
81
+ editorProps: {
82
+ handleKeyDown: (_view, event) => {
83
+ if (event.key === 'Escape') {
84
+ event.preventDefault();
85
+ onCancel();
86
+ return true;
87
+ }
88
+
89
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
90
+ event.preventDefault();
91
+ if (!submitDisabled) {
92
+ onSubmit();
93
+ }
94
+ return true;
95
+ }
96
+
97
+ return false;
98
+ },
99
+ ...installRichTextImageHandlers(image),
100
+ },
101
+ onUpdate: ({ editor: currentEditor }) => {
102
+ onChange(currentEditor.getJSON(), currentEditor.getText());
103
+ },
104
+ },
105
+ // Remount when `disabled` changes (Tiptap doesn't have a runtime
106
+ // setter for `editable`) or when image-config presence toggles (the
107
+ // schema gains/loses the Image extension). The image callbacks
108
+ // themselves are captured at install time, so swapping them without
109
+ // toggling `enableImages` doesn't rebuild the editor.
110
+ [disabled, enableImages],
111
+ );
112
+
113
+ useRichTextImageLoader(editor, image);
114
+
115
+ useEffect(() => {
116
+ if (!editor) {
117
+ return;
118
+ }
119
+
120
+ const current = editor.getJSON();
121
+ if (!docsEqual(current, normalizedValue)) {
122
+ editor.commands.setContent(normalizedValue, { emitUpdate: false });
123
+ }
124
+ }, [editor, normalizedValue]);
125
+
126
+ if (!editor) {
127
+ return null;
128
+ }
129
+
130
+ return (
131
+ <div className={styles.root}>
132
+ <textarea
133
+ aria-label={label}
134
+ className={styles.srOnlyTextarea}
135
+ value={plainTextFromDoc(normalizedValue)}
136
+ onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
137
+ editor.commands.setContent(
138
+ richTextDocFromPlainText(event.target.value),
139
+ { emitUpdate: true },
140
+ );
141
+ }}
142
+ disabled={disabled}
143
+ />
144
+ <FormattingToolbar
145
+ editor={editor}
146
+ commands={STANDARD_FORMATTING_COMMANDS}
147
+ className={styles.toolbar}
148
+ ariaLabel={`${label} formatting`}
149
+ />
150
+
151
+ <div className={`${styles.editor} ${proseStyles.prose}`}>
152
+ <EditorContent editor={editor} />
153
+ </div>
154
+
155
+ <div className={styles.actions}>
156
+ <div className={styles.actionMeta}>
157
+ {submitError ? (
158
+ <div className={styles.errorRow}>
159
+ <Text variant="caption1" c="text.danger.default">
160
+ {submitError}
161
+ </Text>
162
+ {onSubmitErrorRetry ? (
163
+ <Button
164
+ type="button"
165
+ size="xs"
166
+ variant="outline"
167
+ onClick={onSubmitErrorRetry}
168
+ disabled={submitErrorRetryDisabled}
169
+ >
170
+ Retry
171
+ </Button>
172
+ ) : null}
173
+ </div>
174
+ ) : null}
175
+ <Text variant="caption1" className={styles.keyboardHint}>
176
+ Cmd/Ctrl+Enter to submit
177
+ </Text>
178
+ </div>
179
+ <div className={styles.actionButtons}>
180
+ <IconButton
181
+ type="button"
182
+ variant="ghost"
183
+ size="sm"
184
+ aria-label="Cancel"
185
+ onClick={onCancel}
186
+ disabled={cancelDisabled}
187
+ >
188
+ <X size={16} />
189
+ </IconButton>
190
+ <Button
191
+ type="button"
192
+ variant="primary"
193
+ size="xs"
194
+ onClick={onSubmit}
195
+ disabled={submitDisabled}
196
+ loading={submitLoading}
197
+ >
198
+ {submitLabel}
199
+ </Button>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ );
204
+ }
@@ -0,0 +1,2 @@
1
+ export { RichTextEditor } from './RichTextEditor';
2
+ export type { RichTextEditorProps } from './RichTextEditor';
@@ -0,0 +1,11 @@
1
+ import { style } from '@vanilla-extract/css';
2
+
3
+ import { tokens } from '../../theme/themeContract.css';
4
+
5
+ export const root = style({
6
+ color: tokens.color.text.default,
7
+ });
8
+
9
+ export const empty = style({
10
+ color: tokens.color.text.subduedDefault,
11
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * RichTextView
3
+ *
4
+ * Read-only renderer for Tiptap / ProseMirror `JSONContent`. Produces the
5
+ * same prose styling as `RichTextEditor` (via the shared prose class) so
6
+ * display and edit modes look identical.
7
+ *
8
+ * Renders a `placeholder` string in a muted style when the document is
9
+ * empty (no non-whitespace text). Pass `null` for `value` to render the
10
+ * placeholder.
11
+ *
12
+ * @example
13
+ * <RichTextView value={doc} placeholder="No summary yet." />
14
+ */
15
+
16
+ import { useEffect, useMemo } from 'react';
17
+
18
+ import { EditorContent, type JSONContent, useEditor } from '@tiptap/react';
19
+
20
+ import { getRichTextExtensions } from '../RichText/richTextExtensions';
21
+ import {
22
+ docsEqual,
23
+ isDocEmpty,
24
+ normalizeValue,
25
+ } from '../RichText/richTextHelpers';
26
+ import {
27
+ useRichTextImageLoader,
28
+ type RichTextImageEditorConfig,
29
+ } from '../RichText/richTextImageHandlers';
30
+ import * as proseStyles from '../RichText/richTextProse.css';
31
+ import { Text } from '../Typography';
32
+ import * as styles from './RichTextView.css';
33
+
34
+ export interface RichTextViewProps {
35
+ /** Document to render. `null` is treated as empty. */
36
+ value: JSONContent | null;
37
+ /** Text shown (muted) when the document has no visible content. */
38
+ placeholder?: string;
39
+ /** Optional className for the outer wrapper. */
40
+ className?: string;
41
+ /**
42
+ * When provided, enables the Image node in the schema so docs that
43
+ * carry images render faithfully. Pass `loadImage` to resolve
44
+ * `data-rich-text-file-id`s into real `src`s; pass `autoLoad: false`
45
+ * (the default) on surfaces that render many viewers at once (e.g.
46
+ * Assessments) so they don't fan out one request per section. The
47
+ * returned `loadImages()` handle isn't exposed here — for explicit
48
+ * lazy-loading from this surface use `EditableRichText` or wrap your
49
+ * own observer that calls back into a shared loader.
50
+ */
51
+ image?: RichTextImageEditorConfig;
52
+ }
53
+
54
+ export function RichTextView({
55
+ value,
56
+ placeholder,
57
+ className,
58
+ image,
59
+ }: RichTextViewProps) {
60
+ const normalizedValue = useMemo(() => normalizeValue(value), [value]);
61
+ const enableImages = Boolean(image);
62
+
63
+ const editor = useEditor(
64
+ {
65
+ immediatelyRender: false,
66
+ editable: false,
67
+ // `editable: false` flips Link.openOnClick on so anchor clicks navigate
68
+ // when rendered as a view. Same canonical extension set as every editor
69
+ // surface — that's what guarantees a doc renders identically here as it
70
+ // edits in `RichTextEditor`, `EditableRichText`, or `SlashRichTextEditor`.
71
+ extensions: getRichTextExtensions({ editable: false, enableImages }),
72
+ content: normalizedValue,
73
+ },
74
+ [enableImages],
75
+ );
76
+
77
+ useRichTextImageLoader(editor, image);
78
+
79
+ useEffect(() => {
80
+ if (!editor) {
81
+ return;
82
+ }
83
+
84
+ const current = editor.getJSON();
85
+ if (!docsEqual(current, normalizedValue)) {
86
+ editor.commands.setContent(normalizedValue, { emitUpdate: false });
87
+ }
88
+ }, [editor, normalizedValue]);
89
+
90
+ if (isDocEmpty(value) && placeholder) {
91
+ return (
92
+ <Text
93
+ variant="body1"
94
+ className={[styles.empty, className].filter(Boolean).join(' ')}
95
+ >
96
+ {placeholder}
97
+ </Text>
98
+ );
99
+ }
100
+
101
+ if (!editor) {
102
+ return null;
103
+ }
104
+
105
+ return (
106
+ <div
107
+ className={[styles.root, proseStyles.prose, className]
108
+ .filter(Boolean)
109
+ .join(' ')}
110
+ >
111
+ <EditorContent editor={editor} />
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,2 @@
1
+ export { RichTextView } from './RichTextView';
2
+ export type { RichTextViewProps } from './RichTextView';