@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,301 @@
1
+ /**
2
+ * createDisplayStore — one factory for "how should this view look" settings.
3
+ *
4
+ * `docs/design-briefs/tasks-board-header-primitives.md` §4 records two
5
+ * separate defects in the source, and this file exists to make both
6
+ * impossible:
7
+ *
8
+ * 1. THE COPY-PASTE. Their store shape (`Record<Key, boolean>` + `toggle` +
9
+ * `reset` + persist) is duplicated verbatim across FIVE stores. This is
10
+ * one factory called five times.
11
+ *
12
+ * 2. THE SINGLETON — the trap worth inverting. Their cards subscribe to a
13
+ * module-level zustand store directly (`issue-line.tsx:19`,
14
+ * `issue-grid.tsx:81`), so two boards on one page cannot hold different
15
+ * display settings, and a card cannot be rendered in a preview with
16
+ * settings of its own. Here the settings travel through REACT CONTEXT:
17
+ * the state lives in a `<Provider>` instance, so two providers are two
18
+ * independent panels, and a component that wants the settings takes them
19
+ * from the nearest one (or, for a preview, as a plain prop).
20
+ *
21
+ * PERSISTENCE IS PER SCOPE. `scope` is whatever partition the host needs —
22
+ * for a lens that is the brand id, because ADR-116 D10 requires brand A's
23
+ * preferences never to surface on brand B. The key is
24
+ * `<prefix>:<name>:<scope>:display`, matching CLAUDE.md §3's namespacing
25
+ * rule. Changing `scope` re-reads: the provider does not remount, so an
26
+ * in-flight draft elsewhere in the tree survives a brand switch.
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
+ *
39
+ * READING IS DEFENSIVE. `localStorage` is untrusted input — a hand-edited
40
+ * value, a key left behind by an older build, a private window that throws on
41
+ * access. Every persisted key is checked against the default's TYPE and, when
42
+ * the caller declares one, against an allow-list of values. Anything that
43
+ * fails falls back to the default rather than reaching a component as a
44
+ * setting nothing knows how to render.
45
+ */
46
+
47
+ import {
48
+ createContext,
49
+ useCallback,
50
+ useContext,
51
+ useEffect,
52
+ useMemo,
53
+ useRef,
54
+ useState,
55
+ type ReactNode,
56
+ } from 'react'
57
+
58
+ /**
59
+ * What a display setting may be. Enough for a select, a toggle — and a
60
+ * MEASUREMENT.
61
+ *
62
+ * `number` joined the union for a size the operator drags rather than picks
63
+ * (the item detail's panel width). It is validated harder than the other two,
64
+ * because `NaN` and `Infinity` are both `typeof 'number'` and both survive a
65
+ * `JSON.parse` round trip through `null`-free hand editing: a non-finite value
66
+ * reaching a layout is a pane with no width, so it is rejected on read like
67
+ * any other value that does not hold up.
68
+ */
69
+ export type DisplayValue = string | boolean | number
70
+
71
+ /**
72
+ * The settings object. A TYPE alias, not an interface, on purpose: an
73
+ * interface has no implicit index signature and would not satisfy this
74
+ * constraint at the call site.
75
+ */
76
+ export type DisplaySettings = Readonly<Record<string, DisplayValue>>
77
+
78
+ /** Allowed values per key, for the enumerated ones. Unlisted keys are open. */
79
+ export type DisplayAllowed<T extends DisplaySettings> = {
80
+ readonly [K in keyof T]?: readonly T[K][]
81
+ }
82
+
83
+ /**
84
+ * A per-key rewrite applied to the PERSISTED value before it is validated.
85
+ *
86
+ * The narrow case this exists for: a setting whose vocabulary changes, where
87
+ * the old value has an exact successor. Without it the allow-list rejects the
88
+ * stored value and the operator's preference is silently replaced by the
89
+ * default — which is a preference thrown away, not a preference migrated. The
90
+ * tasks lens widened `detailPresentation` from two values to three and its
91
+ * `drawer` became `panel`; nobody who had chosen the side panel should be put
92
+ * back on the default because of it.
93
+ *
94
+ * It runs on UNTRUSTED input, so it takes `unknown` and may return anything:
95
+ * whatever it produces still has to survive the type check and the allow-list
96
+ * afterwards. A migration cannot widen what is permitted, only translate into
97
+ * it.
98
+ */
99
+ export type DisplayMigrations<T extends DisplaySettings> = {
100
+ readonly [K in keyof T]?: (raw: unknown) => unknown
101
+ }
102
+
103
+ export interface DisplayController<T extends DisplaySettings> {
104
+ readonly settings: T
105
+ readonly set: <K extends keyof T>(key: K, value: T[K]) => void
106
+ readonly reset: () => void
107
+ /** True when nothing has been changed from the defaults. */
108
+ readonly isDefault: boolean
109
+ }
110
+
111
+ export interface DisplayProviderProps {
112
+ /**
113
+ * The persistence partition — a brand id in a lens. Omit for a panel that
114
+ * should not persist at all (a preview, a test).
115
+ */
116
+ scope?: string
117
+ children: ReactNode
118
+ }
119
+
120
+ export interface DisplayStore<T extends DisplaySettings> {
121
+ /** Holds the state. Two providers are two independent panels. */
122
+ readonly Provider: (props: DisplayProviderProps) => ReactNode
123
+ /** Reads the nearest provider. Throws outside one — a silent default here
124
+ * would render a board with settings the panel is not editing. */
125
+ readonly useDisplay: () => DisplayController<T>
126
+ readonly defaults: T
127
+ /** Exposed so a host can name the key it is clearing. */
128
+ readonly storageKey: (scope: string) => string
129
+ }
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
+
158
+ function isDisplayValue(value: unknown): value is DisplayValue {
159
+ if (typeof value === 'string' || typeof value === 'boolean') return true
160
+ return typeof value === 'number' && Number.isFinite(value)
161
+ }
162
+
163
+ export function createDisplayStore<T extends DisplaySettings>(
164
+ name: string,
165
+ defaults: T,
166
+ allowed?: DisplayAllowed<T>,
167
+ migrations?: DisplayMigrations<T>,
168
+ options?: DisplayStoreOptions,
169
+ ): DisplayStore<T> {
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`
191
+
192
+ /** One key's persisted value, or `null` when it does not hold up. */
193
+ function validate<K extends keyof T & string>(key: K, input: unknown): T[K] | null {
194
+ const fallback = defaults[key]
195
+ // The migration runs FIRST and on the raw value, then everything below
196
+ // judges its output exactly as it would judge a stored one.
197
+ const migrate = migrations?.[key]
198
+ const raw = migrate === undefined ? input : migrate(input)
199
+ if (!isDisplayValue(raw)) return null
200
+ if (typeof raw !== typeof fallback) return null
201
+ const permitted = allowed?.[key]
202
+ if (permitted && !permitted.includes(raw as T[K])) return null
203
+ return raw as T[K]
204
+ }
205
+
206
+ function read(scope: string | undefined): T {
207
+ if (!scope || typeof window === 'undefined') return defaults
208
+ let parsed: unknown
209
+ try {
210
+ const raw = window.localStorage.getItem(storageKey(scope))
211
+ if (!raw) return defaults
212
+ parsed = JSON.parse(raw)
213
+ } catch {
214
+ // A private window, blocked site data, or a truncated value. The panel
215
+ // opens at its defaults rather than not at all.
216
+ return defaults
217
+ }
218
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
219
+ return defaults
220
+ }
221
+ const record = parsed as Record<string, unknown>
222
+ const next: Record<string, DisplayValue> = { ...defaults }
223
+ for (const key of Object.keys(defaults)) {
224
+ if (!Object.hasOwn(record, key)) continue
225
+ const value = validate(key, record[key])
226
+ if (value !== null) next[key] = value
227
+ }
228
+ return next as unknown as T
229
+ }
230
+
231
+ function write(scope: string | undefined, settings: T): void {
232
+ if (!scope || typeof window === 'undefined') return
233
+ try {
234
+ window.localStorage.setItem(storageKey(scope), JSON.stringify(settings))
235
+ } catch {
236
+ // Never let a full or blocked store break the view.
237
+ }
238
+ }
239
+
240
+ const Ctx = createContext<DisplayController<T> | null>(null)
241
+
242
+ function Provider({ scope, children }: DisplayProviderProps): ReactNode {
243
+ const [settings, setSettings] = useState<T>(() => read(scope))
244
+
245
+ // A scope change RE-READS rather than remounting, so unsaved work
246
+ // elsewhere in the tree survives a brand switch (ADR-116 D10).
247
+ const previousScope = useRef(scope)
248
+ useEffect(() => {
249
+ if (previousScope.current === scope) return
250
+ previousScope.current = scope
251
+ setSettings(read(scope))
252
+ }, [scope])
253
+
254
+ const set = useCallback(
255
+ <K extends keyof T>(key: K, value: T[K]) => {
256
+ setSettings((current) => {
257
+ if (current[key] === value) return current
258
+ const next = { ...current, [key]: value }
259
+ write(scope, next)
260
+ return next
261
+ })
262
+ },
263
+ [scope],
264
+ )
265
+
266
+ const reset = useCallback(() => {
267
+ setSettings(() => {
268
+ write(scope, defaults)
269
+ return defaults
270
+ })
271
+ }, [scope])
272
+
273
+ const value = useMemo<DisplayController<T>>(
274
+ () => ({
275
+ settings,
276
+ set,
277
+ reset,
278
+ isDefault: Object.keys(defaults).every(
279
+ (key) => settings[key] === defaults[key],
280
+ ),
281
+ }),
282
+ [settings, set, reset],
283
+ )
284
+
285
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>
286
+ }
287
+
288
+ function useDisplay(): DisplayController<T> {
289
+ const ctx = useContext(Ctx)
290
+ if (!ctx) {
291
+ throw new Error(
292
+ `[display-store] useDisplay for "${name}" must be called inside its ` +
293
+ `Provider. Rendering with silent defaults would show a view the ` +
294
+ `display panel is not actually editing.`,
295
+ )
296
+ }
297
+ return ctx
298
+ }
299
+
300
+ return { Provider, useDisplay, defaults, storageKey }
301
+ }