@lovett/ui 0.0.11 → 0.2.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 (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * CommentBody — the stored markdown, rendered.
3
+ *
4
+ * ADR-147 D7 and D11.
5
+ *
6
+ * SECURITY, stated once and permanently: `rehype-raw` is NEVER enabled on this
7
+ * surface, and no `rehypePlugins` are passed at all. `react-markdown` does not
8
+ * execute raw HTML unless it is turned on, so the safe state is the default
9
+ * state and this file's job is to not leave it. Comment bodies are user input
10
+ * and will quote LLM output; the repo has zero `rehype-*` packages and zero
11
+ * `dangerouslySetInnerHTML`, and this decision protects that rather than
12
+ * repairing it. Adding either here is a security change requiring its own ADR.
13
+ *
14
+ * MENTIONS (D11) are ordinary markdown links with a scheme:
15
+ *
16
+ * @[Marcus Webb](user:usr_9f2) @[Slice B migration](task_item:tit_44a)
17
+ *
18
+ * Two things follow that are easy to get wrong:
19
+ *
20
+ * • `react-markdown`'s default `urlTransform` drops any protocol it does not
21
+ * recognise, so `user:usr_9f2` would silently become `href=""` and the
22
+ * mention would render as inert text. The transform below preserves
23
+ * exactly the three mention schemes and hands everything else to the
24
+ * default sanitiser unchanged — the allowlist is the point; widening it is
25
+ * how `javascript:` gets back in.
26
+ * • The component map comes from the existing `MarkdownRenderer`
27
+ * (`markdownComponents`) rather than being rewritten. `MarkdownRenderer`
28
+ * itself takes no component overrides, so a mention-aware surface has to
29
+ * drive `ReactMarkdown` with that same map plus one `a` override; forking
30
+ * the map is what puts two markdown looks in one product. The remark
31
+ * plugin list (`markdownRemarkPlugins` — GFM plus ADR-148 D13's underline)
32
+ * is shared the same way, so the two surfaces cannot parse differently.
33
+ */
34
+
35
+ import type { AnchorHTMLAttributes, ImgHTMLAttributes, ReactNode } from 'react'
36
+ import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'
37
+ import { markdownComponents, markdownRemarkPlugins } from '../markdown'
38
+ import { cn } from '../lib/utils'
39
+
40
+ const MENTION_SCHEMES = ['user', 'task_item', 'task_board'] as const
41
+ export type MentionTargetType = (typeof MENTION_SCHEMES)[number]
42
+
43
+ export interface MentionTarget {
44
+ readonly type: MentionTargetType
45
+ readonly id: string
46
+ }
47
+
48
+ const MENTION_PATTERN = new RegExp(`^(${MENTION_SCHEMES.join('|')}):(.+)$`)
49
+
50
+ /** Narrows without a cast — the scheme list is the allowlist, in one place. */
51
+ function isMentionScheme(value: string): value is MentionTargetType {
52
+ return MENTION_SCHEMES.some((scheme) => scheme === value)
53
+ }
54
+
55
+ /** Parse `user:usr_9f2` into a target. Returns null for anything else. */
56
+ export function parseMentionHref(href: string | undefined): MentionTarget | null {
57
+ if (href === undefined) return null
58
+ const match = MENTION_PATTERN.exec(href)
59
+ if (match === null) return null
60
+ const type = match[1]
61
+ const id = match[2]
62
+ if (type === undefined || id === undefined) return null
63
+ if (!isMentionScheme(type)) return null
64
+ return { type, id }
65
+ }
66
+
67
+ function urlTransform(value: string): string {
68
+ return parseMentionHref(value) === null ? defaultUrlTransform(value) : value
69
+ }
70
+
71
+ export interface CommentBodyProps {
72
+ readonly bodyMd: string
73
+ /** Called when a mention chip is activated. Omitted renders an inert chip. */
74
+ readonly onSelectMention?: ((target: MentionTarget) => void) | undefined
75
+ readonly className?: string | undefined
76
+ }
77
+
78
+ export function CommentBody({ bodyMd, onSelectMention, className }: CommentBodyProps) {
79
+ const components = {
80
+ ...markdownComponents,
81
+ // Paragraph spacing inside a comment is tighter than the document default
82
+ // the shared map assumes — a comment is a remark, not an article.
83
+ p: ({ children }: { children?: ReactNode }) => (
84
+ <p className="mb-[var(--space-2)] leading-[1.55] last:mb-0" style={{ color: 'rgb(var(--foreground))' }}>
85
+ {children}
86
+ </p>
87
+ ),
88
+ // Images are outside the composer's subset — ADR-148 D3: "images-by-markdown
89
+ // out; ADR-151 owns attachments" — but `![alt](url)` is still typeable. It
90
+ // renders as its alt text and nothing else: no `<img>`, so no request to a
91
+ // user-supplied URL and no empty-`src` element when the transform strips a
92
+ // dangerous one. ADR-151: this is the entry to revisit when attachments land.
93
+ img: ({ alt }: ImgHTMLAttributes<HTMLImageElement>) =>
94
+ alt === undefined || alt === '' ? null : <span>{alt}</span>,
95
+ a: ({ href, children }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
96
+ const mention = parseMentionHref(href)
97
+ if (mention === null) {
98
+ return (
99
+ <a
100
+ href={href}
101
+ className="underline-offset-2 hover:underline"
102
+ style={{ color: 'rgb(var(--accent-ink))' }}
103
+ target="_blank"
104
+ rel="noopener noreferrer"
105
+ >
106
+ {children}
107
+ </a>
108
+ )
109
+ }
110
+ const interactive = onSelectMention !== undefined
111
+ const Tag = interactive ? 'button' : 'span'
112
+ return (
113
+ <Tag
114
+ {...(interactive ? { type: 'button' as const, onClick: () => { onSelectMention(mention) } } : {})}
115
+ className={cn(
116
+ 'inline-flex items-center align-baseline text-[12.5px] font-semibold',
117
+ 'transition-[background-color,border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
118
+ 'motion-reduce:transition-none',
119
+ 'border border-[rgb(var(--border))] bg-[rgb(var(--surface-inset))] text-[rgb(var(--accent-ink))]',
120
+ interactive &&
121
+ 'hover:border-[rgb(var(--accent))] hover:bg-[rgb(var(--surface-hover))] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
122
+ )}
123
+ style={{
124
+ borderRadius: 'var(--radius-sm)',
125
+ paddingInline: 'var(--space-1)',
126
+ }}
127
+ >
128
+ {children}
129
+ </Tag>
130
+ )
131
+ },
132
+ }
133
+
134
+ return (
135
+ <div
136
+ className={cn('text-[13px]', className)}
137
+ style={{ color: 'rgb(var(--foreground))' }}
138
+ >
139
+ <ReactMarkdown
140
+ components={components}
141
+ remarkPlugins={markdownRemarkPlugins}
142
+ urlTransform={urlTransform}
143
+ >
144
+ {bodyMd}
145
+ </ReactMarkdown>
146
+ </div>
147
+ )
148
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * comment-tree — a flat comment list becomes a reply tree.
3
+ *
4
+ * Ported from `@lovett/community-ui`'s `internal/comment-tree.ts`, with the
5
+ * D4 correction. The shape contract is unchanged and worth restating: a
6
+ * consumer with NO threading data (every `parentId` undefined) gets every
7
+ * comment back as a childless top-level node — the same flat list it put in —
8
+ * and a consumer with real reply-to data gets full nesting, from the same
9
+ * input shape. Nothing has to know in advance which kind of thread it has.
10
+ *
11
+ * WHAT CHANGED FROM THE REFERENCE (ADR-147 D4)
12
+ *
13
+ * The reference re-parents an orphan to top level, unconditionally:
14
+ *
15
+ * // An unmatched parentId (parent not present in this list — deleted, or
16
+ * // simply not fetched) falls back to top-level rather than being dropped.
17
+ *
18
+ * Those two cases are not the same case.
19
+ *
20
+ * • "not fetched" — the parent row is genuinely absent (pagination, a
21
+ * filtered view). Rendering the reply at top level is the least-wrong
22
+ * thing available, but it is a LIE unless it is labelled, so we flag it
23
+ * with `orphanedParentId` and the UI says "in reply to a comment that
24
+ * isn't shown".
25
+ *
26
+ * • "deleted" — the parent is a TOMBSTONE, and a tombstone is a row that is
27
+ * still there (`deleted_at` set, body blanked on read). It matches by id
28
+ * like any other parent, so its children stay nested under it. If deletion
29
+ * ever dropped the row instead, this builder would silently move replies
30
+ * out from under what they were replying to and reshape the conversation.
31
+ *
32
+ * A cycle (a.parent = b, b.parent = a — only reachable through corrupt data,
33
+ * but a stack overflow is not an acceptable response to corrupt data) is
34
+ * treated as the orphan case rather than followed.
35
+ */
36
+
37
+ import type { CommentNode, ThreadComment, ThreadSort } from './types'
38
+
39
+ interface MutableNode extends ThreadComment {
40
+ replies: MutableNode[]
41
+ depth: number
42
+ orphanedParentId: string | null
43
+ }
44
+
45
+ /**
46
+ * Walk up from `parentId`; report whether the chain reaches `childId`.
47
+ * Bounded by the map size so a pre-existing cycle above us cannot spin.
48
+ */
49
+ function createsCycle(
50
+ byId: ReadonlyMap<string, MutableNode>,
51
+ parentId: string,
52
+ childId: string,
53
+ ): boolean {
54
+ let cursor: string | null = parentId
55
+ let steps = 0
56
+ while (cursor !== null) {
57
+ if (cursor === childId) return true
58
+ // Budget blown: the chain is longer than the whole list, which is only
59
+ // possible if it loops. Treat it as a cycle rather than keep walking.
60
+ if (steps > byId.size) return true
61
+ const node: MutableNode | undefined = byId.get(cursor)
62
+ if (node === undefined) return false
63
+ cursor = node.parentId ?? null
64
+ steps += 1
65
+ }
66
+ // The chain terminated at a real root. No cycle.
67
+ return false
68
+ }
69
+
70
+ function assignDepth(node: MutableNode, depth: number): void {
71
+ node.depth = depth
72
+ for (const reply of node.replies) assignDepth(reply, depth + 1)
73
+ }
74
+
75
+ /** Group a flat comment list into the reply tree the recursive renderer needs. */
76
+ export function buildCommentTree(
77
+ comments: readonly ThreadComment[],
78
+ ): readonly CommentNode[] {
79
+ const byId = new Map<string, MutableNode>()
80
+ for (const comment of comments) {
81
+ byId.set(comment.id, {
82
+ ...comment,
83
+ replies: [],
84
+ depth: 0,
85
+ orphanedParentId: null,
86
+ })
87
+ }
88
+
89
+ const roots: MutableNode[] = []
90
+ for (const comment of comments) {
91
+ const node = byId.get(comment.id)
92
+ if (node === undefined) continue
93
+
94
+ const parentId = comment.parentId ?? null
95
+ if (parentId === null) {
96
+ roots.push(node)
97
+ continue
98
+ }
99
+
100
+ const parent = byId.get(parentId)
101
+ if (parent === undefined || createsCycle(byId, parentId, comment.id)) {
102
+ // D4: absent parent, or corrupt data. Never dropped, never silently
103
+ // promoted — surfaced at top level AND flagged.
104
+ node.orphanedParentId = parentId
105
+ roots.push(node)
106
+ continue
107
+ }
108
+
109
+ parent.replies.push(node)
110
+ }
111
+
112
+ for (const root of roots) assignDepth(root, 0)
113
+ return roots
114
+ }
115
+
116
+ /** Total nodes in the forest, including every nested reply. */
117
+ export function countCommentTree(nodes: readonly CommentNode[]): number {
118
+ return nodes.reduce((n, node) => n + 1 + countCommentTree(node.replies), 0)
119
+ }
120
+
121
+ /** Replies below this node, at every depth. Excludes the node itself. */
122
+ export function countDescendants(node: CommentNode): number {
123
+ return countCommentTree(node.replies)
124
+ }
125
+
126
+ /**
127
+ * The engagement figure `top` ranks by: the explicit vote score when the
128
+ * surface uses votes, otherwise the sum of reaction counts.
129
+ */
130
+ export function engagementScore(node: CommentNode): number {
131
+ if (node.score !== undefined) return node.score
132
+ return (node.reactions ?? []).reduce((n, r) => n + r.count, 0)
133
+ }
134
+
135
+ /** Undelivered comments sort last under `top`, so a retry stays where it was typed. */
136
+ function isUndelivered(node: CommentNode): boolean {
137
+ return node.state === 'pending' || node.state === 'failed'
138
+ }
139
+
140
+ function byCreatedAtAsc(a: CommentNode, b: CommentNode): number {
141
+ return a.createdAt - b.createdAt || a.id.localeCompare(b.id)
142
+ }
143
+
144
+ function byTop(a: CommentNode, b: CommentNode): number {
145
+ return (
146
+ Number(isUndelivered(a)) - Number(isUndelivered(b)) ||
147
+ Number(b.pinned ?? false) - Number(a.pinned ?? false) ||
148
+ engagementScore(b) - engagementScore(a) ||
149
+ byCreatedAtAsc(a, b)
150
+ )
151
+ }
152
+
153
+ function byNewest(a: CommentNode, b: CommentNode): number {
154
+ return b.createdAt - a.createdAt || b.id.localeCompare(a.id)
155
+ }
156
+
157
+ function sortReplies(nodes: readonly CommentNode[]): readonly CommentNode[] {
158
+ // Replies are ALWAYS chronological, whichever sort the roots use.
159
+ //
160
+ // A decision ADR-147 did not make, recorded here: Reddit re-sorts every
161
+ // level, which turns a nested exchange into a pile of non-sequiturs under
162
+ // "new" — the reply lands above the line it answers. Roots are a LIST and
163
+ // want ranking; a reply chain is a CONVERSATION and wants order. Ranking
164
+ // therefore applies to roots only.
165
+ return [...nodes]
166
+ .sort(byCreatedAtAsc)
167
+ .map((node) => ({ ...node, replies: sortReplies(node.replies) }))
168
+ }
169
+
170
+ /**
171
+ * Sort the roots by the chosen order; every reply level stays chronological.
172
+ * Deterministic: every comparator falls through to a stable id tie-break, so
173
+ * the same input always produces the same order (CLAUDE.md §7).
174
+ */
175
+ export function sortCommentTree(
176
+ roots: readonly CommentNode[],
177
+ sort: ThreadSort,
178
+ ): readonly CommentNode[] {
179
+ return [...roots]
180
+ .sort(sort === 'top' ? byTop : byNewest)
181
+ .map((node) => ({ ...node, replies: sortReplies(node.replies) }))
182
+ }