@lovett/ui 0.0.10 → 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 (158) 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 +6064 -80
  4. package/dist/index.js +21561 -9696
  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 +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. package/src/tooltip.tsx +297 -0
@@ -0,0 +1,443 @@
1
+ /**
2
+ * remark-underline — `++text++` renders as `<u>` (ADR-148 D13).
3
+ *
4
+ * Markdown has no underline. The rich composer serialises TipTap's
5
+ * `underline` mark as `++text++` — a dialect several Markdown flavours use
6
+ * for insert/underline, and one CommonMark cannot misread because `+` has no
7
+ * inline meaning. This is the RENDERER half: a micromark text construct that
8
+ * tokenizes `++` runs, a resolver that pairs openers with closers, and an
9
+ * mdast handler that emits an `underline` node carrying `data.hName = 'u'`,
10
+ * which `remark-rehype` turns into a `<u>` element for the component map in
11
+ * `markdown.tsx` to style.
12
+ *
13
+ * ZERO DEPENDENCIES, ON PURPOSE. `remark-gfm` already pulls every utility this
14
+ * would want into the lockfile (`micromark-util-classify-character`,
15
+ * `micromark-util-resolve-all`, `micromark-util-chunked`,
16
+ * `mdast-util-from-markdown`, `micromark-util-types`) — but pnpm's strict
17
+ * layout keeps transitives out of `packages/ui/node_modules`, so none of them
18
+ * is importable here without becoming a direct dependency, which D13 forbids.
19
+ * The three helpers are re-derived below (each is under fifteen lines in the
20
+ * original) and the types are local structural mirrors of
21
+ * `micromark-util-types` / `mdast-util-from-markdown`. The runtime contract is
22
+ * plain objects and functions that micromark calls by shape, so a mirror is
23
+ * exactly as safe as the import would be. The construct is modelled line for
24
+ * line on `micromark-extension-gfm-strikethrough` (`~~` → `<del>`), its
25
+ * closest cousin.
26
+ *
27
+ * THE RULES — agreed with the composer's serializer (ADR-148 D13):
28
+ *
29
+ * • A `++` run may OPEN / CLOSE by exactly GFM strikethrough's flanking
30
+ * rule (`micromark-extension-gfm-strikethrough`, which is CommonMark's
31
+ * `*` rule without the underscore exception): classify the character on
32
+ * each side as whitespace, punctuation, or other, then
33
+ *
34
+ * open = after is not whitespace and (after is not punctuation or before is whitespace/punctuation)
35
+ * close = before is not whitespace and (before is not punctuation or after is whitespace/punctuation)
36
+ *
37
+ * So `++text++` behaves the way `~~text~~` and `**text**` already do:
38
+ * `i++; j++;` and `C++` stay literal (a run before punctuation after a
39
+ * letter cannot open), `word++x++word` DOES underline (intraword, like
40
+ * `~~`), `see ++(note)++ here` underlines but `a++(note)++b` does not.
41
+ * Whitespace = end of input, line endings, tabs, spaces, Unicode
42
+ * whitespace; punctuation = Unicode P and S. micromark hands over UTF-16
43
+ * code units, so an astral character (an emoji) arrives as two surrogates
44
+ * and classifies as "other", exactly as it does for `~~`.
45
+ * • Exactly two markers. A lone `+`, a `+++` run, an unclosed `++text`, a
46
+ * closer with no opener, spaces just inside the markers — all stay
47
+ * literal text. Nothing is dropped and nothing throws (D3 rule 3).
48
+ * • Code spans and fences are untouched: micromark never runs text
49
+ * constructs inside them, so `` `++x++` `` shows the plus signs.
50
+ * • `\++` is an ordinary character escape and stays literal.
51
+ * • Registered as an `insideSpan` resolver AND an attention marker — the
52
+ * same two hooks strikethrough uses — so `**++x++**`, `++**x**++`,
53
+ * `~~++x++~~` and the intraword `a**++x++**b` all nest correctly.
54
+ * • Two pairs CAN nest structurally (`++a ++b++ c++` → `<u>a <u>b</u> c</u>`),
55
+ * exactly as `~~` does. The composer never emits that (a mark cannot
56
+ * nest in ProseMirror) and it renders as one underline, so it is pinned
57
+ * rather than prevented.
58
+ */
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Local structural types — mirrors of `micromark-util-types` (see header).
62
+ // Only the members this construct touches are declared.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * A character code. `null` is end of input; negative values are micromark's
67
+ * virtual characters (line endings, tab expansion); everything else is a
68
+ * Unicode code point.
69
+ */
70
+ type Code = number | null
71
+
72
+ interface Point {
73
+ line: number
74
+ column: number
75
+ offset: number
76
+ _index: number
77
+ _bufferIndex: number
78
+ }
79
+
80
+ interface Token {
81
+ type: string
82
+ start: Point
83
+ end: Point
84
+ _open?: boolean
85
+ _close?: boolean
86
+ }
87
+
88
+ interface Resolvable {
89
+ readonly resolveAll?: Resolver | undefined
90
+ }
91
+
92
+ interface TokenizeContext {
93
+ readonly previous: Code
94
+ readonly events: Event[]
95
+ readonly parser: {
96
+ readonly constructs: {
97
+ readonly insideSpan: { readonly null?: readonly Resolvable[] | undefined }
98
+ }
99
+ }
100
+ }
101
+
102
+ type Event = ['enter' | 'exit', Token, TokenizeContext]
103
+ type State = (code: Code) => State | undefined
104
+ type Resolver = (events: Event[], context: TokenizeContext) => Event[]
105
+ type Tokenizer = (this: TokenizeContext, effects: Effects, ok: State, nok: State) => State
106
+
107
+ interface Effects {
108
+ enter(type: string): Token
109
+ exit(type: string): Token
110
+ consume(code: Code): undefined
111
+ }
112
+
113
+ interface Construct extends Resolvable {
114
+ readonly name: string
115
+ readonly tokenize: Tokenizer
116
+ readonly resolveAll: Resolver
117
+ }
118
+
119
+ /** The three hooks a micromark syntax extension may register for an inline construct. */
120
+ interface MicromarkExtension {
121
+ readonly text: Readonly<Record<number, Construct>>
122
+ readonly insideSpan: { readonly null: readonly Construct[] }
123
+ readonly attentionMarkers: { readonly null: readonly Code[] }
124
+ }
125
+
126
+ // Mirrors of `mdast-util-from-markdown` — the compile side.
127
+
128
+ /**
129
+ * The mdast node. `data.hName` is the documented `mdast-util-to-hast` hook:
130
+ * an unknown node type with an `hName` becomes an element of that name, so
131
+ * no rehype plugin of any kind is involved and the raw-HTML ban is untouched.
132
+ */
133
+ interface UnderlineNode {
134
+ type: 'underline'
135
+ children: unknown[]
136
+ data: { hName: 'u' }
137
+ }
138
+
139
+ interface CompileContext {
140
+ enter(node: UnderlineNode, token: Token): undefined
141
+ exit(token: Token): undefined
142
+ }
143
+
144
+ type Handle = (this: CompileContext, token: Token) => undefined
145
+
146
+ interface FromMarkdownExtension {
147
+ readonly canContainEols: readonly string[]
148
+ readonly enter: Readonly<Record<string, Handle>>
149
+ readonly exit: Readonly<Record<string, Handle>>
150
+ }
151
+
152
+ // The slice of a unified processor the plugin touches. `settings` is listed
153
+ // so the type is not "weak" (all-optional) and unified's `Data` — which has
154
+ // `settings` — is assignable to it whether or not remark-parse's augmentation
155
+ // (`micromarkExtensions` / `fromMarkdownExtensions`) is loaded in a consumer.
156
+ interface RemarkData {
157
+ settings?: unknown
158
+ micromarkExtensions?: unknown[]
159
+ fromMarkdownExtensions?: unknown[]
160
+ }
161
+
162
+ interface RemarkProcessorLike {
163
+ data(): RemarkData
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Constants
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /** U+002B PLUS SIGN. */
171
+ const PLUS = 43
172
+ /** U+0020 SPACE. */
173
+ const SPACE = 32
174
+
175
+ const SEQUENCE_TEMPORARY = 'underlineSequenceTemporary'
176
+ const SEQUENCE = 'underlineSequence'
177
+ const UNDERLINE = 'underline'
178
+ const UNDERLINE_TEXT = 'underlineText'
179
+ /** micromark's own token types this construct reads. */
180
+ const CHARACTER_ESCAPE = 'characterEscape'
181
+ const DATA = 'data'
182
+
183
+ /** `Array#splice` spreads its items as arguments; past this many V8 overflows the stack. */
184
+ const SPLICE_CHUNK = 10_000
185
+
186
+ /** `micromark-util-character`'s `unicodeWhitespace` / `unicodePunctuation`. */
187
+ const UNICODE_WHITESPACE = /\s/
188
+ const UNICODE_PUNCTUATION = /\p{P}|\p{S}/u
189
+
190
+ /** `micromark-util-symbol`'s character groups. */
191
+ const GROUP_WHITESPACE = 1
192
+ const GROUP_PUNCTUATION = 2
193
+ type CharacterGroup = typeof GROUP_WHITESPACE | typeof GROUP_PUNCTUATION | undefined
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Re-derived helpers (see header for why these are not imports)
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /**
200
+ * `micromark-util-classify-character`: whitespace (end of input, any virtual
201
+ * character — all line endings and tab parts are negative codes — the space,
202
+ * Unicode whitespace), punctuation (Unicode P and S), or other. Codes are
203
+ * UTF-16 units and are stringified with `fromCharCode` exactly as micromark's
204
+ * `regexCheck` does, so surrogate halves classify as "other" here too.
205
+ */
206
+ function classifyCharacter(code: Code): CharacterGroup {
207
+ if (code === null || code < 0 || code === SPACE) return GROUP_WHITESPACE
208
+ const character = String.fromCharCode(code)
209
+ if (UNICODE_WHITESPACE.test(character)) return GROUP_WHITESPACE
210
+ if (UNICODE_PUNCTUATION.test(character)) return GROUP_PUNCTUATION
211
+ return undefined
212
+ }
213
+
214
+ /** `micromark-util-chunked`'s `splice`: `Array#splice` that survives huge item lists. */
215
+ function spliceChunked<T>(list: T[], start: number, remove: number, items: readonly T[]): void {
216
+ const end = list.length
217
+ let at = start < 0 ? (-start > end ? 0 : end + start) : start > end ? end : start
218
+ const count = remove > 0 ? remove : 0
219
+
220
+ if (items.length < SPLICE_CHUNK) {
221
+ list.splice(at, count, ...items)
222
+ return
223
+ }
224
+
225
+ if (count > 0) list.splice(at, count)
226
+ let from = 0
227
+ while (from < items.length) {
228
+ list.splice(at, 0, ...items.slice(from, from + SPLICE_CHUNK))
229
+ from += SPLICE_CHUNK
230
+ at += SPLICE_CHUNK
231
+ }
232
+ }
233
+
234
+ /** `micromark-util-resolve-all`: run each distinct `resolveAll` once, in order. */
235
+ function resolveAll(
236
+ constructs: readonly Resolvable[],
237
+ events: Event[],
238
+ context: TokenizeContext,
239
+ ): Event[] {
240
+ const called: Resolver[] = []
241
+ let result = events
242
+ for (const construct of constructs) {
243
+ const resolve = construct.resolveAll
244
+ if (resolve !== undefined && !called.includes(resolve)) {
245
+ result = resolve(result, context)
246
+ called.push(resolve)
247
+ }
248
+ }
249
+ return result
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // The micromark construct
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /**
257
+ * Tokenize one `++` run. Runs whenever the text tokenizer meets a `+`.
258
+ * Emits a temporary sequence token flagged with whether it may open and/or
259
+ * close; the resolver pairs them afterwards.
260
+ */
261
+ function tokenizeUnderline(
262
+ this: TokenizeContext,
263
+ effects: Effects,
264
+ ok: State,
265
+ nok: State,
266
+ ): State {
267
+ const previous = this.previous
268
+ const events = this.events
269
+ let size = 0
270
+
271
+ return start
272
+
273
+ function start(code: Code): State | undefined {
274
+ // A `+` directly after another `+` that was not an escape means we are
275
+ // inside a run an earlier attempt already rejected (`+++`): stay literal.
276
+ if (previous === PLUS && events[events.length - 1]?.[1].type !== CHARACTER_ESCAPE) {
277
+ return nok(code)
278
+ }
279
+ effects.enter(SEQUENCE_TEMPORARY)
280
+ return more(code)
281
+ }
282
+
283
+ function more(code: Code): State | undefined {
284
+ if (code === PLUS) {
285
+ // A third marker is not this syntax.
286
+ if (size > 1) return nok(code)
287
+ effects.consume(code)
288
+ size++
289
+ return more
290
+ }
291
+ if (size < 2) return nok(code)
292
+
293
+ const token = effects.exit(SEQUENCE_TEMPORARY)
294
+ // GFM strikethrough's flanking rule, verbatim (see header).
295
+ const before = classifyCharacter(previous)
296
+ const after = classifyCharacter(code)
297
+ token._open = after === undefined || (after === GROUP_PUNCTUATION && before !== undefined)
298
+ token._close = before === undefined || (before === GROUP_PUNCTUATION && after !== undefined)
299
+ return ok(code)
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Pair closers with the nearest earlier opener, wrapping the events between
305
+ * in `underline` / `underlineText`, and demote every sequence that did not
306
+ * pair to plain `data` so its characters render as typed.
307
+ */
308
+ function resolveAllUnderline(events: Event[], context: TokenizeContext): Event[] {
309
+ let index = -1
310
+
311
+ while (++index < events.length) {
312
+ const closer = events[index]
313
+ if (
314
+ closer === undefined ||
315
+ closer[0] !== 'enter' ||
316
+ closer[1].type !== SEQUENCE_TEMPORARY ||
317
+ closer[1]._close !== true
318
+ ) {
319
+ continue
320
+ }
321
+
322
+ let open = index
323
+ while (open--) {
324
+ const opener = events[open]
325
+ if (
326
+ opener === undefined ||
327
+ opener[0] !== 'exit' ||
328
+ opener[1].type !== SEQUENCE_TEMPORARY ||
329
+ opener[1]._open !== true ||
330
+ // Sizes must match — always two here, kept for parity with the model.
331
+ closer[1].end.offset - closer[1].start.offset !==
332
+ opener[1].end.offset - opener[1].start.offset
333
+ ) {
334
+ continue
335
+ }
336
+
337
+ closer[1].type = SEQUENCE
338
+ opener[1].type = SEQUENCE
339
+
340
+ const underline: Token = {
341
+ type: UNDERLINE,
342
+ start: { ...opener[1].start },
343
+ end: { ...closer[1].end },
344
+ }
345
+ const text: Token = {
346
+ type: UNDERLINE_TEXT,
347
+ start: { ...opener[1].end },
348
+ end: { ...closer[1].start },
349
+ }
350
+
351
+ // Opening.
352
+ const next: Event[] = [
353
+ ['enter', underline, context],
354
+ ['enter', opener[1], context],
355
+ ['exit', opener[1], context],
356
+ ['enter', text, context],
357
+ ]
358
+
359
+ // Between — let every other span construct (emphasis, strong,
360
+ // strikethrough, this one again) resolve inside the pair.
361
+ const insideSpan = context.parser.constructs.insideSpan.null
362
+ if (insideSpan !== undefined) {
363
+ spliceChunked(
364
+ next,
365
+ next.length,
366
+ 0,
367
+ resolveAll(insideSpan, events.slice(open + 1, index), context),
368
+ )
369
+ }
370
+
371
+ // Closing.
372
+ spliceChunked(next, next.length, 0, [
373
+ ['exit', text, context],
374
+ ['enter', closer[1], context],
375
+ ['exit', closer[1], context],
376
+ ['exit', underline, context],
377
+ ])
378
+
379
+ spliceChunked(events, open - 1, index - open + 3, next)
380
+ index = open + next.length - 2
381
+ break
382
+ }
383
+ }
384
+
385
+ // Whatever did not pair is text. Never dropped, never thrown (D3 rule 3).
386
+ for (const event of events) {
387
+ if (event[1].type === SEQUENCE_TEMPORARY) event[1].type = DATA
388
+ }
389
+
390
+ return events
391
+ }
392
+
393
+ function underlineSyntax(): MicromarkExtension {
394
+ const construct: Construct = {
395
+ name: 'underline',
396
+ tokenize: tokenizeUnderline,
397
+ resolveAll: resolveAllUnderline,
398
+ }
399
+ return {
400
+ text: { [PLUS]: construct },
401
+ insideSpan: { null: [construct] },
402
+ attentionMarkers: { null: [PLUS] },
403
+ }
404
+ }
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // The mdast compile extension
408
+ // ---------------------------------------------------------------------------
409
+
410
+ function enterUnderline(this: CompileContext, token: Token): undefined {
411
+ this.enter({ type: UNDERLINE, children: [], data: { hName: 'u' } }, token)
412
+ }
413
+
414
+ function exitUnderline(this: CompileContext, token: Token): undefined {
415
+ this.exit(token)
416
+ }
417
+
418
+ function underlineFromMarkdown(): FromMarkdownExtension {
419
+ return {
420
+ // A soft line break inside `++a\nb++` is kept as text, like `delete`.
421
+ canContainEols: [UNDERLINE],
422
+ enter: { [UNDERLINE]: enterUnderline },
423
+ exit: { [UNDERLINE]: exitUnderline },
424
+ }
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // The remark plugin
429
+ // ---------------------------------------------------------------------------
430
+
431
+ /**
432
+ * remark plugin: `++text++` → `<u>`. Register after `remarkGfm` in a
433
+ * `remarkPlugins` list. Same shape as `remark-gfm` itself — it appends one
434
+ * micromark syntax extension and one mdast compile extension to the
435
+ * processor's data and returns nothing.
436
+ */
437
+ export function remarkUnderline(this: RemarkProcessorLike): undefined {
438
+ const data = this.data()
439
+ const micromark = data.micromarkExtensions ?? (data.micromarkExtensions = [])
440
+ const fromMarkdown = data.fromMarkdownExtensions ?? (data.fromMarkdownExtensions = [])
441
+ micromark.push(underlineSyntax())
442
+ fromMarkdown.push(underlineFromMarkdown())
443
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * series.ts — the categorical chart palette, resolved by a frozen literal
3
+ * lookup and nothing else.
4
+ *
5
+ * Promoted in ADR-146 D8. Canonical home for the palette so `ChartLegend`,
6
+ * `ChartTooltip` and every chart primitive resolve a hue the same way.
7
+ * Re-exported from `chart-legend.tsx` for the barrel.
8
+ *
9
+ * CONSUMERS — measured 2026-09-06, real imports inside `@lovett/ui` (7):
10
+ * `chart-legend.tsx`, `chart-tooltip.tsx`, `line-chart.tsx`,
11
+ * `bar-chart.tsx`, `donut-chart.tsx`, `ranked-bars.tsx`, `sparkline.tsx`.
12
+ * `ChartFrame` does NOT import it — it renders chrome and takes an
13
+ * already-coloured legend node — so the earlier claim naming it was wrong.
14
+ *
15
+ * WHY A FROZEN ARRAY AND NOT A CONSTRUCTED NAME
16
+ * ----------------------------------------------
17
+ * `ENGINEERING-AXES.md:186` records the exact defect this shape exists to
18
+ * prevent. A constructed custom-property name (`--folder-${key}`) produced
19
+ * `--folder-folder-violet`, which is undefined, which makes the enclosing
20
+ * `rgb()` invalid, which the browser **silently discards**. Typecheck, lint,
21
+ * tests, build and the token gate were all green while white-on-white tiles
22
+ * shipped. There is no gate that reads source and can see it, because the
23
+ * source is syntactically perfect.
24
+ *
25
+ * So no primitive in this package ever composes `` `--series-${i}` ``. The
26
+ * mapping is the literal array below, the same shape as `metric-card.tsx`'s
27
+ * `TONE_COLOR`, and `__tests__/chart-series.test.ts` asserts — with a
28
+ * mutation proof that the guard fails in the failing direction — that every
29
+ * entry names a custom property that `tokens.css` actually declares.
30
+ *
31
+ * Token discipline: this module produces `rgb(var(--token))` strings only. It
32
+ * contains no colour literal, and every token it names is a **bare RGB
33
+ * triple** in `tokens.css`, so a call site may legally apply its own alpha —
34
+ * `rgb(var(--series-1) / 0.12)` for the area fill under a line. That is
35
+ * asserted too: a series token that grew a baked-in alpha would make every
36
+ * such call site invalid CSS.
37
+ *
38
+ * CALLERS PASS A SLOT INDEX, NEVER A COLOUR AND NEVER A TOKEN NAME.
39
+ */
40
+
41
+ /**
42
+ * The eight categorical series colours, in ADR-146 D2's **measured** order —
43
+ * blue, green, violet, olive, pink, amber, teal, red.
44
+ *
45
+ * The order is load-bearing, not cosmetic:
46
+ *
47
+ * • **Blue is slot 1, not red.** Slot 1 is the single-series default, so a
48
+ * red slot 1 would paint every one-series chart in the product accent and
49
+ * destroy accent scarcity.
50
+ * • **Red is slot 8.** Red belongs to `--accent` and `--destructive`. It
51
+ * enters a chart only at eight series.
52
+ * • **Green (slot 2) and red (slot 8) sit far apart**, so a two-series
53
+ * chart never accidentally reads as pass/fail.
54
+ *
55
+ * Worst adjacent pair is ΔE 13.2 (deutan) / 21.0 (normal), identical in both
56
+ * themes — the palette is theme-independent by construction.
57
+ */
58
+ export const SERIES = [
59
+ 'rgb(var(--series-1))',
60
+ 'rgb(var(--series-2))',
61
+ 'rgb(var(--series-3))',
62
+ 'rgb(var(--series-4))',
63
+ 'rgb(var(--series-5))',
64
+ 'rgb(var(--series-6))',
65
+ 'rgb(var(--series-7))',
66
+ 'rgb(var(--series-8))',
67
+ ] as const
68
+
69
+ /**
70
+ * The all-pairs palette, capped at three (ADR-146 D2).
71
+ *
72
+ * Scatter, bubble, choropleth and small-multiples let any two marks touch, so
73
+ * they take the harder all-pairs colour-blindness test instead of the
74
+ * adjacent-pairs one. **Our eight hues cannot pass it — no ordering can.**
75
+ * These three (`--series-1` blue, `--series-6` amber, `--series-7` teal) do,
76
+ * at worst ΔE 14.8 in both modes.
77
+ *
78
+ * A scatter-family chart with more than three series folds the remainder into
79
+ * "Other" or facets into small multiples. It never generates a ninth hue.
80
+ */
81
+ export const SERIES_SCATTER = [SERIES[0], SERIES[5], SERIES[6]] as const
82
+
83
+ /** How many slots the default palette has. */
84
+ export const SERIES_SLOT_COUNT = SERIES.length
85
+
86
+ /** How many slots the all-pairs (`scatter`) palette has. */
87
+ export const SCATTER_SLOT_COUNT = SERIES_SCATTER.length
88
+
89
+ /**
90
+ * A legal slot in the default palette.
91
+ *
92
+ * Typed as a union rather than `number` so an out-of-range slot is a **type
93
+ * error at the boundary** (ADR-146 D8) rather than a runtime `undefined` that
94
+ * becomes invalid CSS. Callers holding a plain `number` narrow with
95
+ * `isSeriesSlot` and decide explicitly what a ninth series does.
96
+ */
97
+ export type SeriesSlot = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
98
+
99
+ /** A legal slot in the `scatter` palette. */
100
+ export type ScatterSlot = 0 | 1 | 2
101
+
102
+ /** Which palette a chart draws from. `scatter` is the all-pairs, 3-slot one. */
103
+ export type SeriesPalette = 'default' | 'scatter'
104
+
105
+ /** Narrow a plain number to a default-palette slot. */
106
+ export function isSeriesSlot(value: number): value is SeriesSlot {
107
+ return Number.isInteger(value) && value >= 0 && value < SERIES_SLOT_COUNT
108
+ }
109
+
110
+ /** Narrow a plain number to a `scatter`-palette slot. */
111
+ export function isScatterSlot(value: number): value is ScatterSlot {
112
+ return Number.isInteger(value) && value >= 0 && value < SCATTER_SLOT_COUNT
113
+ }
114
+
115
+ /** The default-palette colour for a slot. Total — the union type guarantees it. */
116
+ export function seriesColor(slot: SeriesSlot): string {
117
+ return SERIES[slot]
118
+ }
119
+
120
+ /** The `scatter`-palette colour for a slot. Total. */
121
+ export function scatterColor(slot: ScatterSlot): string {
122
+ return SERIES_SCATTER[slot]
123
+ }
124
+
125
+ /**
126
+ * The colour for `slot` in `palette`, or `undefined` when the slot is outside
127
+ * that palette's cap.
128
+ *
129
+ * `undefined` is the deliberate answer rather than a wrap-around or a clamp:
130
+ * ADR-146 D2 says a fourth scatter series folds into "Other", and silently
131
+ * reusing slot 1's blue for slot 4 would draw two different entities in one
132
+ * hue — a wrong chart that no gate can see. Consumers render the "Other"
133
+ * treatment (a neutral swatch) when this returns `undefined`.
134
+ */
135
+ export function paletteColor(
136
+ palette: SeriesPalette,
137
+ slot: number,
138
+ ): string | undefined {
139
+ if (palette === 'scatter') {
140
+ return isScatterSlot(slot) ? scatterColor(slot) : undefined
141
+ }
142
+ return isSeriesSlot(slot) ? seriesColor(slot) : undefined
143
+ }
144
+
145
+ /**
146
+ * A palette slot, or the explicit `'other'` fold.
147
+ *
148
+ * `'other'` is how a chart says "this mark is the remainder". ADR-146 D2 caps
149
+ * the palette at eight slots (three for `scatter`) and folds the rest rather
150
+ * than generating a ninth hue, so the fold has to be representable in the
151
+ * data — not improvised at render time.
152
+ */
153
+ export type ChartSlot = SeriesSlot | 'other'
154
+
155
+ /** The neutral swatch for the `'other'` fold — a grey, never a hue. */
156
+ export const OTHER_SLOT_COLOR = 'rgb(var(--text-muted))'
157
+
158
+ /**
159
+ * The rendered colour for a `ChartSlot`. Total: anything the palette cannot
160
+ * legally colour — the `'other'` fold, or a slot past a palette's cap — comes
161
+ * back as the neutral swatch rather than wrapping onto a hue already in use.
162
+ *
163
+ * Shared by `ChartLegend` and `ChartTooltip` so a series cannot be one colour
164
+ * in the legend and another in the readout.
165
+ */
166
+ export function slotColor(slot: ChartSlot, palette: SeriesPalette): string {
167
+ if (slot === 'other') return OTHER_SLOT_COLOR
168
+ return paletteColor(palette, slot) ?? OTHER_SLOT_COLOR
169
+ }