@lovett/ui 0.1.0 → 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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { useState } from 'react'
10
10
  import { describe, expect, it, vi } from 'vitest'
11
- import { render, screen, within } from '@testing-library/react'
11
+ import { cleanup, render, screen, within } from '@testing-library/react'
12
12
  import userEvent from '@testing-library/user-event'
13
13
  import { ActivityPane } from '../activity-pane'
14
14
  import type { ThreadComment } from '../../thread/types'
@@ -184,3 +184,186 @@ describe('without a comment backend', () => {
184
184
  expect(screen.getByRole('textbox')).toBeInTheDocument()
185
185
  })
186
186
  })
187
+
188
+ /**
189
+ * FU-0019 — the pane is a conduit, and it used to be a leaky one.
190
+ *
191
+ * `ActivityPaneProps` declared `onSubmit` and `onReply` and stopped, so three
192
+ * mutations `<Thread>` already accepted could not be reached through the pane
193
+ * at all. Nothing errored, which is why it survived a release: `CommentItem`
194
+ * draws no control for a callback it was not given, so a host shipped an inert
195
+ * engagement row and a one-line overflow menu while the routes behind them had
196
+ * no caller anywhere in the browser.
197
+ *
198
+ * These assert at the CONTROL, never at the prop. A prop that arrives and
199
+ * lands nowhere is the same bug one layer further in.
200
+ */
201
+ describe('ActivityPane — forwarding the thread callbacks', () => {
202
+ const WIRED: readonly ThreadComment[] = [
203
+ {
204
+ id: 'c1',
205
+ author: { id: 'u1', name: 'Ada Lovelace' },
206
+ bodyMd: 'Looks right to me.',
207
+ createdAt: NOW - 60_000,
208
+ reactions: [{ key: 'up', count: 7, viewerReacted: true }],
209
+ },
210
+ {
211
+ id: 'c-failed',
212
+ author: { id: 'u2', name: 'Grace Hopper' },
213
+ bodyMd: 'This one never left the device.',
214
+ createdAt: NOW - 30_000,
215
+ state: 'failed',
216
+ errorText: 'This comment did not send.',
217
+ },
218
+ ]
219
+
220
+ /** Mounts the pane and opens Comments, which is where `<Thread>` lives. */
221
+ async function comments(
222
+ props: Partial<React.ComponentProps<typeof ActivityPane>> = {},
223
+ ): Promise<HTMLElement> {
224
+ render(
225
+ <ActivityPane
226
+ comments={WIRED}
227
+ activity={activity}
228
+ draft=""
229
+ onDraftChange={() => {}}
230
+ onSubmit={() => {}}
231
+ now={NOW}
232
+ locale="en-US"
233
+ {...props}
234
+ />,
235
+ )
236
+ await userEvent.click(screen.getByRole('tab', { name: /Comments/ }))
237
+ return panel('Comments')
238
+ }
239
+
240
+ it('reports a reaction key from the chip the thread drew', async () => {
241
+ const onReact = vi.fn()
242
+ const shown = await comments({ onReact })
243
+ await userEvent.click(within(shown).getByRole('button', { name: 'Agree — 7' }))
244
+ expect(onReact).toHaveBeenCalledWith('c1', 'up')
245
+ })
246
+
247
+ it('offers a picker, so a reaction can come into existence at all', async () => {
248
+ const shown = await comments({ onReact: vi.fn() })
249
+ expect(within(shown).getByRole('button', { name: 'Add a reaction' })).toBeInTheDocument()
250
+ })
251
+
252
+ it('puts Edit and Delete in the overflow menu and reports the comment id', async () => {
253
+ const onEdit = vi.fn()
254
+ const onDelete = vi.fn()
255
+ const shown = await comments({ onEdit, onDelete })
256
+ await userEvent.click(
257
+ within(shown).getByRole('button', { name: "More actions for Ada Lovelace's comment" }),
258
+ )
259
+ await userEvent.click(screen.getByRole('menuitem', { name: 'Edit' }))
260
+ expect(onEdit).toHaveBeenCalledWith('c1')
261
+
262
+ await userEvent.click(
263
+ within(shown).getByRole('button', { name: "More actions for Ada Lovelace's comment" }),
264
+ )
265
+ const remove = screen.getByRole('menuitem', { name: 'Delete' })
266
+ expect(remove.getAttribute('data-variant')).toBe('destructive')
267
+ await userEvent.click(remove)
268
+ expect(onDelete).toHaveBeenCalledWith('c1')
269
+ })
270
+
271
+ it('recovers a send that failed in its OWN composer', async () => {
272
+ const onRetry = vi.fn()
273
+ const shown = await comments({ onRetry })
274
+ await userEvent.click(within(shown).getByRole('button', { name: 'Try again' }))
275
+ expect(onRetry).toHaveBeenCalledWith('c-failed')
276
+ })
277
+
278
+ it('draws none of them for a host that wires none — the 0.1.0 render', async () => {
279
+ const shown = await comments()
280
+ // What FU-0019 measured in a browser: a written reaction is readable and
281
+ // inert, and there is no picker, so a reaction can never be created.
282
+ expect(within(shown).getByRole('button', { name: 'Agree — 7' })).toBeDisabled()
283
+ expect(within(shown).queryByRole('button', { name: 'Add a reaction' })).toBeNull()
284
+ expect(within(shown).queryByRole('button', { name: 'Try again' })).toBeNull()
285
+ // The failure itself is still announced; only the recovery is missing.
286
+ expect(within(shown).getByText('This comment did not send.')).toBeInTheDocument()
287
+ // And no overflow menu at all: with no Edit, no Delete, no Copy link and
288
+ // no clipboard under jsdom, `CommentActions` has nothing to put in one and
289
+ // renders none. In a real browser it holds exactly one line — "Copy text",
290
+ // the single item that needs no callback — which is what FU-0019 measured.
291
+ expect(
292
+ within(shown).queryByRole('button', { name: "More actions for Ada Lovelace's comment" }),
293
+ ).toBeNull()
294
+ })
295
+ })
296
+
297
+ /**
298
+ * FU-0032 — the Everything feed draws its own tombstone, and it has to agree
299
+ * with the one the Comments tab draws for the same comment.
300
+ */
301
+ describe('ActivityPane — a moderated comment in the merged feed', () => {
302
+ // One tombstone, two removals. Derived from a single base so the ONLY
303
+ // difference between the two renders is the field under test.
304
+ const BASE: ThreadComment = {
305
+ id: 'c-mod',
306
+ author: { id: 'u1', name: 'Ada Lovelace' },
307
+ bodyMd: '',
308
+ createdAt: NOW - 60_000,
309
+ deletedAt: NOW - 30_000,
310
+ }
311
+
312
+ const REMOVED: readonly ThreadComment[] = [{ ...BASE, moderated: true }]
313
+ const SELF: readonly ThreadComment[] = [{ ...BASE, id: 'c-self', moderated: false }]
314
+
315
+ function feed(list: readonly ThreadComment[]): HTMLElement {
316
+ render(
317
+ <ActivityPane
318
+ comments={list}
319
+ activity={[]}
320
+ draft=""
321
+ onDraftChange={() => {}}
322
+ onSubmit={() => {}}
323
+ now={NOW}
324
+ />,
325
+ )
326
+ return panel('Everything')
327
+ }
328
+
329
+ it('says an admin removed it', () => {
330
+ expect(feed(REMOVED)).toHaveTextContent('Comment removed by an admin')
331
+ })
332
+
333
+ it('still says "Comment deleted" for an author\'s own delete', () => {
334
+ const shown = feed(SELF)
335
+ expect(shown).toHaveTextContent('Comment deleted')
336
+ expect(shown).not.toHaveTextContent('removed')
337
+ })
338
+
339
+ /**
340
+ * The feed WITHHOLDS the author of a tombstone, exactly as the thread does.
341
+ *
342
+ * Found in review. `FeedLine` was given `author` unconditionally, so the
343
+ * Everything tab printed "Ada Lovelace commented" beside "Comment removed by
344
+ * an admin" while the Comments tab one click away replaced her byline with
345
+ * `[removed]` and dropped her avatar to `?`. The same comment answered "who
346
+ * wrote this" two different ways depending on which tab you were on.
347
+ *
348
+ * It was harmless while the line read "Comment deleted" — a deletion nobody
349
+ * had to account for. FU-0032 is what made it matter: a name beside "removed
350
+ * by an admin" is a per-author public record of who got moderated, readable
351
+ * by everyone in the workspace, and it is the one fact the thread render
352
+ * deliberately refuses to publish.
353
+ *
354
+ * Asserted for BOTH removals, because the leak was never specific to
355
+ * moderation — only its consequence was.
356
+ */
357
+ it('withholds the author of a tombstone, on both kinds of removal', () => {
358
+ expect(feed(REMOVED)).not.toHaveTextContent('Ada Lovelace')
359
+ cleanup()
360
+ expect(feed(SELF)).not.toHaveTextContent('Ada Lovelace')
361
+ })
362
+
363
+ it('still names the author of a comment that is NOT a tombstone', () => {
364
+ const live: readonly ThreadComment[] = [
365
+ { ...BASE, id: 'c-live', deletedAt: null, bodyMd: 'Still here.' },
366
+ ]
367
+ expect(feed(live)).toHaveTextContent('Ada Lovelace')
368
+ })
369
+ })
@@ -26,6 +26,16 @@
26
26
  * change — never derived on the client — so this component has no business
27
27
  * inferring history from an item, and does not.
28
28
  *
29
+ * ## It owns no thread behaviour either
30
+ *
31
+ * Every comment callback it declares is `ThreadProps`' own, forwarded
32
+ * unchanged to the `<Thread>` below. The pane decides WHERE a control lives —
33
+ * one composer, outside the panels — and never what one does. That is not a
34
+ * style preference: a prop it fails to forward is a control a host cannot draw
35
+ * at all, and three shipped mutations spent a release unreachable that way
36
+ * (Lightwork FU-0019). What is still not forwarded, and why, is listed on
37
+ * `ActivityPaneProps`, so the next gap is chosen rather than discovered.
38
+ *
29
39
  * ## Everything is read-only
30
40
  *
31
41
  * The merged feed interleaves both by time so you can see what happened in
@@ -43,7 +53,7 @@ import { RelativeTime } from '../thread/relative-time'
43
53
  import { Thread } from '../thread/thread'
44
54
  import { ThreadComposer } from '../thread/composer'
45
55
  import { useNowTick } from '../thread/use-now'
46
- import type { ThreadAuthor, ThreadComment } from '../thread/types'
56
+ import type { ThreadAuthor, ThreadComment, ThreadReactionKey } from '../thread/types'
47
57
  import type { ActivityEntry, FieldPerson } from './types'
48
58
 
49
59
  export type ActivityTab = 'everything' | 'comments' | 'activity'
@@ -106,6 +116,68 @@ export interface ActivityPaneProps {
106
116
  onSubmit?: ((body: string) => void) | undefined
107
117
  /** A reply. Omitted, the thread offers no reply control at all. */
108
118
  onReply?: ((body: string, parentId: string) => void) | undefined
119
+
120
+ /**
121
+ * Forwarded verbatim to the `<Thread>` the Comments tab already mounts.
122
+ *
123
+ * These four are here because the pane used to declare `onSubmit` and
124
+ * `onReply` and stop. `CommentItem` gates its reaction PICKER on `onReact`
125
+ * and its overflow MENU ITEMS on `onEdit` / `onDelete`; a menu item whose
126
+ * callback is absent is not rendered, and the reaction chips still render
127
+ * DISABLED rather than vanishing. (An earlier draft of this docblock said
128
+ * the whole bar was "NOT RENDERED". That is the rule from `ThreadProps`'
129
+ * overflow-menu docblock, and it does not generalise to the engagement row —
130
+ * `reactions.tsx` renders `disabled={!canReact}`.) So a host composing this
131
+ * pane shipped an inert
132
+ * engagement row with no picker to open and a menu holding one line, while
133
+ * the three routes behind them had no caller in the browser at all. Nothing
134
+ * errored, which is how it survived a release; Lightwork's FU-0019 found it
135
+ * by opening the surface rather than by reading the source, and the source
136
+ * understates it.
137
+ *
138
+ * `onRetry` is the same omission with one sharper edge: the failed send it
139
+ * recovers was posted by THIS pane's own composer, so dropping it left the
140
+ * pane manufacturing a state and offering nobody a way out of it.
141
+ *
142
+ * The signatures are `ThreadProps`', unchanged, because a conduit that
143
+ * reshapes what passes through it is a second contract to keep in step —
144
+ * and this one is held by two repos. Absent still means no control, so a
145
+ * host that passes none renders exactly as it did on 0.1.0.
146
+ *
147
+ * STILL NOT FORWARDED, deliberately. This list is EXHAUSTIVE against
148
+ * `ThreadProps` — check it against that interface before adding one, because
149
+ * a list whose whole purpose is completeness is worse than no list when it
150
+ * is short: `onVote`, `onCopyLink`, `onCopyText`, `onSelectAuthor`,
151
+ * `onSelectMention`, `onOpenAttachment`, `resolveAttachmentUrl`,
152
+ * `onContinueThread`.
153
+ *
154
+ * The line is whether a MUTATION becomes unreachable. `onVote` fails it for
155
+ * a different reason — the pane never exposes `engagement`, so there is no
156
+ * vote control to receive it and forwarding it would be a dead prop.
157
+ * `onCopyText` only reports a clipboard outcome; "Copy text" renders without
158
+ * it. `onContinueThread` falls back to local state, so the button works
159
+ * unwired. The rest are reads.
160
+ *
161
+ * `onHideLinkPreview` USED to be on this list and should not have been: it is
162
+ * a write in the same pattern as the other mutations, and `link-preview.tsx`
163
+ * gates its dismiss control on the callback being present. Since the pane
164
+ * forwards `comments` verbatim — link previews included — a host got
165
+ * dismissible-looking rows with no route to the mutation. Same shape as the
166
+ * defect this whole change fixes, caught in review.
167
+ */
168
+ onReact?: ((commentId: string, key: ThreadReactionKey) => void) | undefined
169
+ onEdit?: ((commentId: string) => void) | undefined
170
+ onDelete?: ((commentId: string) => void) | undefined
171
+ onRetry?: ((commentId: string) => void) | undefined
172
+
173
+ /**
174
+ * A write, in the same pattern as the mutations above — `link-preview.tsx`
175
+ * gates its dismiss control on this being present, and the pane forwards
176
+ * `comments` verbatim, previews included. Without it a host draws rows that
177
+ * look dismissible and are not. Added in review, not in the original change.
178
+ */
179
+ onHideLinkPreview?: ((commentId: string, urlHash: string) => void) | undefined
180
+
109
181
  sending?: boolean | undefined
110
182
  composerAvatar?: ReactNode | undefined
111
183
  composerPlaceholder?: string | undefined
@@ -131,6 +203,11 @@ export function ActivityPane({
131
203
  onDraftChange,
132
204
  onSubmit,
133
205
  onReply,
206
+ onReact,
207
+ onEdit,
208
+ onDelete,
209
+ onRetry,
210
+ onHideLinkPreview,
134
211
  sending = false,
135
212
  composerAvatar,
136
213
  composerPlaceholder = 'Add a comment…',
@@ -255,7 +332,22 @@ export function ActivityPane({
255
332
  item.kind === 'comment' ? (
256
333
  <li key={`comment-${item.comment.id}`}>
257
334
  <FeedLine
258
- author={item.comment.author ?? null}
335
+ // WITHHELD on a tombstone, matching the thread's treatment
336
+ // exactly. `CommentItem` replaces the byline with the
337
+ // tombstone label and drops the avatar to `?`; printing the
338
+ // name here would mean the same comment answers "who wrote
339
+ // this" differently one tab apart.
340
+ //
341
+ // It matters more since FU-0032 than it did before. A
342
+ // neutral "Comment deleted" beside a name was a deletion
343
+ // nobody had to explain; "Comment removed by an admin"
344
+ // beside a name is a per-author public record of who got
345
+ // moderated, readable by everyone in the workspace.
346
+ author={
347
+ item.comment.deletedAt === null || item.comment.deletedAt === undefined
348
+ ? (item.comment.author ?? null)
349
+ : null
350
+ }
259
351
  at={item.at}
260
352
  now={clock}
261
353
  locale={locale}
@@ -263,8 +355,15 @@ export function ActivityPane({
263
355
  item.comment.deletedAt === null || item.comment.deletedAt === undefined ? (
264
356
  <CommentBody bodyMd={item.comment.bodyMd} />
265
357
  ) : (
358
+ // The feed's register, not the thread's: a line in a
359
+ // sentence rather than a bracketed placeholder. The
360
+ // DISTINCTION is the same one, and it has to be, or a
361
+ // reader gets one answer on Everything and another on
362
+ // Comments about the same comment (FU-0032).
266
363
  <span style={{ color: 'rgb(var(--text-tertiary))' }}>
267
- Comment deleted
364
+ {item.comment.moderated === true
365
+ ? 'Comment removed by an admin'
366
+ : 'Comment deleted'}
268
367
  </span>
269
368
  )
270
369
  }
@@ -312,6 +411,11 @@ export function ActivityPane({
312
411
  if (parentId !== undefined) onReply(body, parentId)
313
412
  },
314
413
  })}
414
+ {...(onReact === undefined ? {} : { onReact })}
415
+ {...(onEdit === undefined ? {} : { onEdit })}
416
+ {...(onDelete === undefined ? {} : { onDelete })}
417
+ {...(onRetry === undefined ? {} : { onRetry })}
418
+ {...(onHideLinkPreview === undefined ? {} : { onHideLinkPreview })}
315
419
  />
316
420
  </div>
317
421
  )}
@@ -21,10 +21,21 @@
21
21
  * PERSISTENCE IS PER SCOPE. `scope` is whatever partition the host needs —
22
22
  * for a lens that is the brand id, because ADR-116 D10 requires brand A's
23
23
  * preferences never to surface on brand B. The key is
24
- * `workspace:<name>:<scope>:display`, matching CLAUDE.md §3's namespacing
24
+ * `<prefix>:<name>:<scope>:display`, matching CLAUDE.md §3's namespacing
25
25
  * rule. Changing `scope` re-reads: the provider does not remount, so an
26
26
  * in-flight draft elsewhere in the tree survives a brand switch.
27
27
  *
28
+ * THE PREFIX IS THE PRODUCT, AND ITS DEFAULT IS FROZEN. This package is a
29
+ * dependency of more than one app on more than one origin, and `workspace` is
30
+ * only the right first segment for one of them: a host whose own convention is
31
+ * `lightwork:<area>:<id>:<key>` was getting the one key on its board that read
32
+ * as somebody else's. So `prefix` is an option — and its default is `workspace`
33
+ * FOREVER, not because that name is good but because these keys already exist
34
+ * in operators' browsers. A store holds a preference the operator set; changing
35
+ * the default segment would not move that preference to a new key, it would
36
+ * walk past it and open at the defaults. The suite pins the unprefixed key as a
37
+ * literal for that reason.
38
+ *
28
39
  * READING IS DEFENSIVE. `localStorage` is untrusted input — a hand-edited
29
40
  * value, a key left behind by an older build, a private window that throws on
30
41
  * access. Every persisted key is checked against the default's TYPE and, when
@@ -117,6 +128,33 @@ export interface DisplayStore<T extends DisplaySettings> {
117
128
  readonly storageKey: (scope: string) => string
118
129
  }
119
130
 
131
+ /**
132
+ * Everything about the store that is not its settings.
133
+ *
134
+ * An OBJECT rather than a fifth positional string, because `allowed` and
135
+ * `migrations` are both optional and already both objects: a call reading
136
+ * `createDisplayStore('board', DEFAULTS, undefined, undefined, 'lightwork')`
137
+ * puts two bare strings at opposite ends of an argument list and names
138
+ * neither. `{ prefix: 'lightwork' }` says what it is at the call site, and the
139
+ * next option that earns its place goes beside it rather than becoming a sixth
140
+ * positional.
141
+ */
142
+ export interface DisplayStoreOptions {
143
+ /**
144
+ * The key's first segment — the PRODUCT, above the store's own `name`.
145
+ *
146
+ * Omit it and the key is `workspace:<name>:<scope>:display`, exactly as it
147
+ * has always been. Pass one and the store moves wholesale onto it, so two
148
+ * apps sharing this primitive on one origin under the same `name` cannot
149
+ * read or overwrite each other.
150
+ *
151
+ * It is not a migration seam. A store that changes prefix does not carry its
152
+ * stored settings across; it opens at its defaults on the new key and leaves
153
+ * the old one behind. Choose it once, when the store is written.
154
+ */
155
+ prefix?: string
156
+ }
157
+
120
158
  function isDisplayValue(value: unknown): value is DisplayValue {
121
159
  if (typeof value === 'string' || typeof value === 'boolean') return true
122
160
  return typeof value === 'number' && Number.isFinite(value)
@@ -127,8 +165,29 @@ export function createDisplayStore<T extends DisplaySettings>(
127
165
  defaults: T,
128
166
  allowed?: DisplayAllowed<T>,
129
167
  migrations?: DisplayMigrations<T>,
168
+ options?: DisplayStoreOptions,
130
169
  ): DisplayStore<T> {
131
- const storageKey = (scope: string) => `workspace:${name}:${scope}:display`
170
+ // Resolved once, at construction, so the key cannot differ between the read
171
+ // on mount and the write on the next change.
172
+ const prefix = options?.prefix ?? 'workspace'
173
+ // An EMPTY prefix is refused rather than defaulted, and loudly, because both
174
+ // quieter options are worse. Falling back to `workspace` would hand a caller
175
+ // who asked for isolation the shared namespace instead — silently, and
176
+ // discoverable only as two products reading each other's settings. Accepting
177
+ // it produces `:board:ws_1:display`, whose empty first segment collides with
178
+ // every other empty-prefix caller: precisely the cross-product this option
179
+ // exists to prevent, reintroduced by the option itself.
180
+ //
181
+ // A throw at CONSTRUCTION is safe where a throw on read would not be: the
182
+ // store is built once, at module scope, so this fires before any UI exists
183
+ // rather than under a user mid-session. `useDisplay` outside a Provider
184
+ // already throws for the same reason.
185
+ if (prefix === '') {
186
+ throw new Error(
187
+ 'createDisplayStore: `prefix` cannot be empty — omit the option to use the default.',
188
+ )
189
+ }
190
+ const storageKey = (scope: string) => `${prefix}:${name}:${scope}:display`
132
191
 
133
192
  /** One key's persisted value, or `null` when it does not hold up. */
134
193
  function validate<K extends keyof T & string>(key: K, input: unknown): T[K] | null {
package/src/index.ts CHANGED
@@ -78,6 +78,12 @@ export { SortableList, SortableItem, DragHandle } from './sortable'
78
78
  export {
79
79
  MultiSortableList,
80
80
  SortableDropZone,
81
+ // The default `collisionDetection`, exported so a consumer overriding the
82
+ // prop can COMPOSE with it rather than replace it. Without this, the only way
83
+ // to add one rule is to reimplement pointer-first resolution — which is how a
84
+ // component ends up with two collision strategies that disagree.
85
+ pointerFirstCollision,
86
+ PREVIEW_SETTLE_MS,
81
87
  type MultiSortableListProps,
82
88
  type MultiSortableMove,
83
89
  type SortableContainers,
@@ -672,6 +678,7 @@ export {
672
678
  type DisplayAllowed,
673
679
  type DisplayMigrations,
674
680
  type DisplayProviderProps,
681
+ type DisplayStoreOptions,
675
682
  } from './display-store'
676
683
  // ── ADR-146 — the analytics primitive set ─────────────────────────────
677
684
  //