@estiva-app/ui 0.39.0 → 0.41.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.
@@ -0,0 +1,149 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the RichText page claims, pinned: every block draws as its element; text
4
+ * is text, never HTML; links are found in text and never in code, and a click
5
+ * on one stays on it; an unknown block keeps its words; a table's first row of
6
+ * header cells is its header; the app's own parts go where the page says.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
10
+ import { RichText, richTextClassName, type RichTextBlock, type RichTextRun } from './RichText'
11
+
12
+ afterEach(cleanup)
13
+
14
+ const t = (text: string, ...marks: string[]): RichTextRun => ({ text, marks })
15
+ const p = (...inline: RichTextRun[]): RichTextBlock => ({ type: 'paragraph', inline })
16
+ const cell = (typeName: string, text: string): RichTextBlock => ({ type: 'unknown', typeName, children: [p(t(text))] })
17
+ const row = (typeName: string, ...texts: string[]): RichTextBlock => ({ type: 'unknown', typeName: 'tableRow', children: texts.map((x) => cell(typeName, x)) })
18
+
19
+ const draw = (blocks: RichTextBlock[], props = {}) => render(<RichText blocks={blocks} {...props} />).container
20
+
21
+ describe('RichText', () => {
22
+ it('draws each block as its element', () => {
23
+ const c = draw([
24
+ { type: 'heading', level: 1, inline: [t('One')] },
25
+ { type: 'heading', level: 2, inline: [t('Two')] },
26
+ { type: 'heading', level: 3, inline: [t('Three')] },
27
+ p(t('Text')),
28
+ { type: 'bulletList', children: [{ type: 'listItem', inline: [t('a')] }] },
29
+ { type: 'orderedList', start: 3, children: [{ type: 'listItem', inline: [t('b')] }] },
30
+ { type: 'blockquote', inline: [t('quoted')] },
31
+ { type: 'codeBlock', inline: [t('const a = 1')] },
32
+ { type: 'horizontalRule' },
33
+ ])
34
+ expect(c.querySelector('h1')?.textContent).toBe('One')
35
+ expect(c.querySelector('h2')?.textContent).toBe('Two')
36
+ expect(c.querySelector('h3')?.textContent).toBe('Three')
37
+ expect(c.querySelector('ul li')?.textContent).toBe('a')
38
+ expect(c.querySelector('ol')?.getAttribute('start')).toBe('3')
39
+ expect(c.querySelector('blockquote')?.textContent).toBe('quoted')
40
+ expect(c.querySelector('pre code')?.textContent).toBe('const a = 1')
41
+ expect(c.querySelector('hr')).not.toBeNull()
42
+ })
43
+
44
+ it('draws the marks as elements, and a line break as a break', () => {
45
+ const c = draw([p(t('b', 'bold'), t('i', 'italic'), t('u', 'underline'), t('c', 'code'), t('one\ntwo'))])
46
+ expect(c.querySelector('strong')?.textContent).toBe('b')
47
+ expect(c.querySelector('em')?.textContent).toBe('i')
48
+ expect(c.querySelector('u')?.textContent).toBe('u')
49
+ expect(c.querySelector('p > code')?.textContent).toBe('c')
50
+ expect(c.querySelectorAll('br').length).toBe(1)
51
+ })
52
+
53
+ it('sets text as text, never as HTML', () => {
54
+ const c = draw([p(t('<b>stays text</b><img src=x onerror=alert(1)>'))])
55
+ expect(c.querySelector('b')).toBeNull()
56
+ expect(c.querySelector('img')).toBeNull()
57
+ expect(c.textContent).toContain('<b>stays text</b>')
58
+ })
59
+
60
+ it('finds a written link and a bare address, and leaves punctuation after it', () => {
61
+ draw([p(t('Read [the guide](https://example.com/guide) or https://example.com/a.'))])
62
+ const guide = screen.getByRole('link', { name: 'the guide' })
63
+ expect(guide.getAttribute('href')).toBe('https://example.com/guide')
64
+ expect(guide.getAttribute('target')).toBe('_blank')
65
+ const bare = screen.getByRole('link', { name: 'https://example.com/a' })
66
+ expect(bare.getAttribute('href')).toBe('https://example.com/a')
67
+ expect(bare.parentElement?.textContent).toBe('https://example.com/a.')
68
+ })
69
+
70
+ it('never reads code for links', () => {
71
+ const c = draw([p(t('[not a link](https://example.com)', 'code'))])
72
+ expect(c.querySelector('a')).toBeNull()
73
+ })
74
+
75
+ it('keeps a click on a link to the link', () => {
76
+ const outer = vi.fn()
77
+ render(
78
+ <div onClick={outer}>
79
+ <RichText blocks={[p(t('https://example.com'))]} />
80
+ </div>,
81
+ )
82
+ fireEvent.click(screen.getByRole('link'))
83
+ expect(outer).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('hands the app only the text between links', () => {
87
+ const seen: string[] = []
88
+ draw([p(t('before https://example.com/@name after'))], { renderText: (text: string) => (seen.push(text), text) })
89
+ expect(seen).toEqual(['before ', ' after'])
90
+ })
91
+
92
+ it('lets the app draw a reference, and draws its text when it does not', () => {
93
+ const run: RichTextRun = { text: 'nostr:npub1x', marks: [], reference: 'nostr:npub1x' }
94
+ expect(draw([p(run)]).textContent).toBe('nostr:npub1x')
95
+ cleanup()
96
+ draw([p(run)], { renderReference: (reference: string) => <mark>{reference.slice(6)}</mark> })
97
+ expect(document.querySelector('mark')?.textContent).toBe('npub1x')
98
+ })
99
+
100
+ it('draws the words of a block it does not know, never dropping it', () => {
101
+ const c = draw([{ type: 'unknown', typeName: 'later', inline: [t('still here')], children: [p(t('and this'))] }])
102
+ const block = c.querySelector('[data-block-type="later"]')
103
+ expect(block?.textContent).toBe('still hereand this')
104
+ })
105
+
106
+ it('lets the app draw a block, or leave it to the part', () => {
107
+ const own = (block: RichTextBlock) => (block.type === 'attachment' ? <figure>own</figure> : undefined)
108
+ const c = draw([{ type: 'attachment' }, p(t('text'))], { renderBlock: own })
109
+ expect(c.querySelector('figure')?.textContent).toBe('own')
110
+ expect(c.querySelector('p')?.textContent).toBe('text')
111
+ })
112
+
113
+ it('draws a table, its first row of header cells as the header', () => {
114
+ const c = draw([{ type: 'table', children: [row('tableHeader', 'Label', 'Value'), row('tableCell', 'a', 'b')] }])
115
+ expect([...c.querySelectorAll('thead th')].map((th) => th.textContent)).toEqual(['Label', 'Value'])
116
+ expect([...c.querySelectorAll('tbody td')].map((td) => td.textContent)).toEqual(['a', 'b'])
117
+ })
118
+
119
+ it('draws a table it cannot read as its words', () => {
120
+ const c = draw([{ type: 'table', inline: [t('flat')] }])
121
+ expect(c.querySelector('table')).toBeNull()
122
+ expect(c.textContent).toBe('flat')
123
+ })
124
+
125
+ it('carries each block id', () => {
126
+ const c = draw([{ type: 'paragraph', id: 'b1', inline: [t('x')] }])
127
+ expect(c.querySelector('p')?.getAttribute('data-block-id')).toBe('b1')
128
+ })
129
+
130
+ it('draws nothing for no blocks', () => {
131
+ expect(draw([]).innerHTML).toBe('')
132
+ })
133
+
134
+ it('keeps the same elements at the small size', () => {
135
+ const blocks: RichTextBlock[] = [{ type: 'heading', level: 1, inline: [t('One')] }, { type: 'bulletList', children: [{ type: 'listItem', inline: [t('a')] }] }]
136
+ const c = draw(blocks, { size: 'small' })
137
+ expect(c.querySelector('h1')).not.toBeNull()
138
+ expect(c.querySelector('ul li')).not.toBeNull()
139
+ expect(c.firstElementChild?.className).toContain('text-caption')
140
+ })
141
+
142
+ it('gives an editor the same look: the classes on its box are the ones an editor gets', () => {
143
+ for (const size of ['default', 'small'] as const) {
144
+ const c = draw([p(t('x'))], { size })
145
+ expect(c.firstElementChild?.className).toBe(richTextClassName(size))
146
+ }
147
+ expect(richTextClassName('default', 'outline-none')).toContain('outline-none')
148
+ })
149
+ })
@@ -0,0 +1,294 @@
1
+ import type { MouseEvent, ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+ import { Link } from './Link'
4
+
5
+ /**
6
+ * Formatted text, drawn: headings, paragraphs, lists, a quote, code, a divider,
7
+ * a table and links.
8
+ *
9
+ * Both apps drew the same thing twice, from the same source — the text tree the
10
+ * protocol package builds (`toRenderTree` → `RenderBlock`, `RenderInline`) —
11
+ * and the two had drifted: headings, list markers, quotes and code differed,
12
+ * one app had tables and the other dropped them, one had links and the other
13
+ * printed the brackets (UIG-30). The look here is Katerina's picks of
14
+ * 28 September, one per difference, most of them Peek's.
15
+ *
16
+ * **It takes the tree, not the text.** The caller parses (the format comes from
17
+ * the item's tag, never from the body) and hands the blocks in. So this package
18
+ * does not depend on the protocol, and a caller can leave out a block it draws
19
+ * elsewhere before passing the rest. The types below are the tree's shape; the
20
+ * protocol's own types fit them.
21
+ *
22
+ * **Safe by construction.** The tree has nothing left to parse, and every
23
+ * string here reaches the page as a React child, so it is text. Nothing is set
24
+ * as HTML.
25
+ *
26
+ * **What a word points at stays the app's.** A person, an item from another
27
+ * app, a name the app recognises in plain text: `renderReference`,
28
+ * `renderText` and `renderBlock` are where each app draws its own.
29
+ */
30
+
31
+ /** A run of text and the marks on it — the protocol's `RenderInline`. */
32
+ export interface RichTextRun {
33
+ text: string
34
+ marks: readonly string[]
35
+ /** A `nostr:` address this run stands for. `text` is the address itself. */
36
+ reference?: string
37
+ }
38
+
39
+ /** A block of the tree — the protocol's `RenderBlock`. */
40
+ export interface RichTextBlock {
41
+ type: string
42
+ /** What the document called a block the tree does not know. */
43
+ typeName?: string
44
+ /** The block's address, when it has one. Drawn as `data-block-id`. */
45
+ id?: string
46
+ level?: 1 | 2 | 3
47
+ language?: string
48
+ /** A numbered list's first number. */
49
+ start?: number
50
+ attrs?: Record<string, unknown>
51
+ inline?: readonly RichTextRun[]
52
+ children?: readonly RichTextBlock[]
53
+ }
54
+
55
+ export type RichTextSize = 'default' | 'small'
56
+
57
+ export interface RichTextProps {
58
+ /** The tree, already parsed. */
59
+ blocks: readonly RichTextBlock[]
60
+ /**
61
+ * Default `default`, the size of text you read. `small` is the same text one
62
+ * step down, with every heading, list, quote and table kept: an item shown
63
+ * inside a card.
64
+ */
65
+ size?: RichTextSize
66
+ /**
67
+ * Draws plain text: what is left of a run once its links are drawn. Where an
68
+ * app turns words it recognises into chips. By default the text as it is.
69
+ */
70
+ renderText?: (text: string) => ReactNode
71
+ /** Draws a run that stands for an address. By default its text. */
72
+ renderReference?: (reference: string, run: RichTextRun) => ReactNode
73
+ /**
74
+ * Draws a block this part has no design for (an attachment, a widget). Return
75
+ * `undefined` to leave it to the part, which draws its words.
76
+ */
77
+ renderBlock?: (block: RichTextBlock) => ReactNode | undefined
78
+ }
79
+
80
+ /**
81
+ * The whole look, as classes on the box around the text: the elements inside
82
+ * carry none. That is so an editor can wear the same classes on its own box and
83
+ * look the same as what it writes — ProseMirror draws the same elements, with a
84
+ * paragraph inside each list item, quote and table cell (the `> p` lines).
85
+ */
86
+ const RICH_TEXT_CLASSES = {
87
+ base: 'flex flex-col gap-1 break-words text-text-secondary',
88
+ /* A heading binds to what follows it: 12px above (8 and the 4px gap), 8px below (4 and the gap); two headings in a row, 12px apart. Level 3 keeps the text's colour, one step lower. */
89
+ headings: '[&>:is(h1,h2,h3)]:mb-1 [&>:is(h1,h2,h3)]:mt-2 [&>:is(h1,h2,h3):first-child]:mt-0 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2)]:text-text-primary',
90
+ lists: '[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-muted [&_ol]:list-decimal [&_ul]:list-disc',
91
+ // eslint-disable-next-line token-spacing/no-restricted-classes -- @estiva-escape: the quote line is Peek's, 3px, between the ramp's 2 and 4
92
+ quote: '[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border-strong [&_blockquote]:pl-2.5',
93
+ // eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.9 of the text around it, so it scales with a heading and with `small`; a named size is fixed
94
+ code: '[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_code]:text-text-primary',
95
+ // eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.9 of the text around it; the code inside the block is the block's own size, not 0.9 of it again
96
+ codeBlock: '[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:p-2 [&_pre]:font-mono [&_pre]:text-[0.9em] [&_pre]:text-text-primary',
97
+ divider: '[&_hr]:border-border-default',
98
+ table: '[&_:is(th,td)>p]:m-0 [&_:is(th,td)]:border [&_:is(th,td)]:border-border-default [&_:is(th,td)]:px-2 [&_:is(th,td)]:py-1 [&_:is(th,td)]:text-left [&_:is(th,td)]:align-top [&_table]:w-full [&_table]:border-collapse [&_th]:bg-bg-code [&_th]:font-medium [&_th]:text-text-primary',
99
+ marks: '[&_em]:italic [&_strong]:font-semibold [&_u]:underline [&_u]:underline-offset-2',
100
+ default: 'text-body-2 [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4',
101
+ small: 'text-caption leading-snug [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold',
102
+ }
103
+
104
+ /**
105
+ * RichText's look, for a box you draw yourself: an editor's, so that what is
106
+ * being written looks the same as what is read. Put it on the editor's root.
107
+ */
108
+ export function richTextClassName(size: RichTextSize = 'default', className?: string) {
109
+ const { base, headings, lists, quote, code, codeBlock, divider, table, marks } = RICH_TEXT_CLASSES
110
+ return cn(base, headings, lists, quote, code, codeBlock, divider, table, marks, RICH_TEXT_CLASSES[size], className)
111
+ }
112
+
113
+ /**
114
+ * A link written as `[words](address)`, then a bare address. The written form
115
+ * first, so its address is not found a second time on its own.
116
+ */
117
+ const LINK_SPLIT = /(\[[^[\]\n]+\]\((?:https?:\/\/|mailto:)[^\s)]+\)|https?:\/\/[^\s]+)/g
118
+ const WRITTEN_LINK = /^\[([^[\]\n]+)\]\(((?:https?:\/\/|mailto:)[^\s)]+)\)$/
119
+ /** Punctuation after a bare address ends the sentence, not the address. */
120
+ const BARE_LINK = /^(https?:\/\/\S+?)([.,;:!?)\]]*)$/
121
+
122
+ /** A link in text is not also a click on what the text sits in, such as a card. */
123
+ const keepToLink = (event: MouseEvent) => event.stopPropagation()
124
+
125
+ function textWithLinks(text: string, renderText: (text: string) => ReactNode): ReactNode {
126
+ const parts = text.split(LINK_SPLIT)
127
+ if (parts.length === 1) return renderText(text)
128
+ return parts.map((part, i) => {
129
+ const written = part.match(WRITTEN_LINK)
130
+ if (written) {
131
+ return (
132
+ <span key={i} className="break-words">
133
+ <Link href={written[2]} external onClick={keepToLink}>{written[1]}</Link>
134
+ </span>
135
+ )
136
+ }
137
+ const bare = i % 2 === 1 ? part.match(BARE_LINK) : null
138
+ if (bare) {
139
+ return (
140
+ // An address has no spaces to wrap at, so it may break anywhere.
141
+ <span key={i} className="break-all">
142
+ <Link href={bare[1]} external onClick={keepToLink}>{bare[1]}</Link>
143
+ {bare[2]}
144
+ </span>
145
+ )
146
+ }
147
+ return part ? <span key={i}>{renderText(part)}</span> : null
148
+ })
149
+ }
150
+
151
+ interface Draw {
152
+ renderText: (text: string) => ReactNode
153
+ renderReference: (reference: string, run: RichTextRun) => ReactNode
154
+ renderBlock?: (block: RichTextBlock) => ReactNode | undefined
155
+ }
156
+
157
+ function Runs({ runs, draw }: { runs?: readonly RichTextRun[]; draw: Draw }) {
158
+ return (
159
+ <>
160
+ {(runs ?? []).map((run, i) => {
161
+ if (run.reference) return <span key={i}>{draw.renderReference(run.reference, run)}</span>
162
+ // Code carries no other mark, and its words are never read for links.
163
+ if (run.marks.includes('code')) {
164
+ return <code key={i}>{run.text}</code>
165
+ }
166
+ // A line break inside a run is a line break.
167
+ let node: ReactNode = run.text.split('\n').map((line, j) => (
168
+ <span key={j}>
169
+ {j > 0 && <br />}
170
+ {textWithLinks(line, draw.renderText)}
171
+ </span>
172
+ ))
173
+ if (run.marks.includes('underline')) node = <u>{node}</u>
174
+ if (run.marks.includes('italic')) node = <em>{node}</em>
175
+ if (run.marks.includes('bold')) node = <strong>{node}</strong>
176
+ return <span key={i}>{node}</span>
177
+ })}
178
+ </>
179
+ )
180
+ }
181
+
182
+ const named = (block: RichTextBlock) => block.typeName ?? block.type
183
+
184
+ /**
185
+ * A table's rows and cells are named by whatever wrote the document; these are
186
+ * the editor's names. A first row of header cells is the header. Returns null
187
+ * for a nesting it does not recognise, which is then drawn as words.
188
+ */
189
+ function Table({ block, draw }: { block: RichTextBlock; draw: Draw }) {
190
+ const rows = (block.children ?? []).filter((row) => named(row) === 'tableRow')
191
+ if (!rows.length) return null
192
+ const cells = (row: RichTextBlock) =>
193
+ (row.children ?? []).map((cell, i) => {
194
+ const header = named(cell) === 'tableHeader'
195
+ const Cell = header ? 'th' : 'td'
196
+ const { colspan, rowspan } = (cell.attrs ?? {}) as { colspan?: number; rowspan?: number }
197
+ return (
198
+ <Cell
199
+ key={cell.id ?? i}
200
+ data-block-id={cell.id}
201
+ colSpan={colspan && colspan > 1 ? colspan : undefined}
202
+ rowSpan={rowspan && rowspan > 1 ? rowspan : undefined}
203
+ >
204
+ {(cell.children ?? []).map((child, j) => <Runs key={j} runs={child.inline} draw={draw} />)}
205
+ <Runs runs={cell.inline} draw={draw} />
206
+ </Cell>
207
+ )
208
+ })
209
+ const first = rows[0].children ?? []
210
+ const headerRow = first.length > 0 && first.every((cell) => named(cell) === 'tableHeader')
211
+ return (
212
+ <table data-block-id={block.id}>
213
+ {headerRow && (
214
+ <thead>
215
+ <tr data-block-id={rows[0].id}>{cells(rows[0])}</tr>
216
+ </thead>
217
+ )}
218
+ <tbody>
219
+ {(headerRow ? rows.slice(1) : rows).map((row, i) => (
220
+ <tr key={row.id ?? i} data-block-id={row.id}>{cells(row)}</tr>
221
+ ))}
222
+ </tbody>
223
+ </table>
224
+ )
225
+ }
226
+
227
+ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode {
228
+ const own = draw.renderBlock?.(block)
229
+ if (own !== undefined) return own
230
+ const runs = <Runs runs={block.inline} draw={draw} />
231
+ switch (block.type) {
232
+ case 'heading': {
233
+ if (block.level === 1) return <h1 data-block-id={block.id}>{runs}</h1>
234
+ if (block.level === 3) return <h3 data-block-id={block.id}>{runs}</h3>
235
+ return <h2 data-block-id={block.id}>{runs}</h2>
236
+ }
237
+ case 'paragraph':
238
+ return <p data-block-id={block.id}>{runs}</p>
239
+ case 'bulletList':
240
+ case 'orderedList': {
241
+ const items = (block.children ?? []).map((item, i) => (
242
+ <li key={item.id ?? i} data-block-id={item.id}>
243
+ <Runs runs={item.inline} draw={draw} />
244
+ </li>
245
+ ))
246
+ return block.type === 'bulletList'
247
+ ? <ul data-block-id={block.id}>{items}</ul>
248
+ : <ol data-block-id={block.id} start={block.start}>{items}</ol>
249
+ }
250
+ case 'blockquote':
251
+ return <blockquote data-block-id={block.id}>{runs}</blockquote>
252
+ case 'codeBlock':
253
+ // Literal: no marks, no links, no chips. A long line wraps; nothing scrolls sideways.
254
+ return (
255
+ <pre data-block-id={block.id} data-language={block.language}>
256
+ <code>{(block.inline ?? []).map((run) => run.text).join('')}</code>
257
+ </pre>
258
+ )
259
+ case 'horizontalRule':
260
+ return <hr data-block-id={block.id} />
261
+ case 'table': {
262
+ const table = <Table block={block} draw={draw} />
263
+ if ((block.children ?? []).some((row) => named(row) === 'tableRow')) return table
264
+ break
265
+ }
266
+ }
267
+ /*
268
+ Anything else — a type this part has no design for, or one published after
269
+ it was written — is drawn as its words, never dropped: a block that
270
+ vanishes looks like one you are not allowed to see.
271
+ */
272
+ return (
273
+ <div data-block-id={block.id} data-block-type={named(block)}>
274
+ {runs}
275
+ {(block.children ?? []).map((child, i) => <Block key={child.id ?? i} block={child} draw={draw} />)}
276
+ </div>
277
+ )
278
+ }
279
+
280
+ export function RichText({
281
+ blocks,
282
+ size = 'default',
283
+ renderText = (text) => text,
284
+ renderReference = (_reference, run) => run.text,
285
+ renderBlock,
286
+ }: RichTextProps) {
287
+ if (blocks.length === 0) return null
288
+ const draw: Draw = { renderText, renderReference, renderBlock }
289
+ return (
290
+ <div data-rich-text className={richTextClassName(size)}>
291
+ {blocks.map((block, i) => <Block key={block.id ?? i} block={block} draw={draw} />)}
292
+ </div>
293
+ )
294
+ }
@@ -139,7 +139,7 @@ describe('an app lint with configs.recommended', () => {
139
139
  [
140
140
  'estiva/no-restyled-part',
141
141
  2,
142
- '`text-h2` on `Link` changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in. Use its `variant` or `truncate`. A look for what is around it goes on your own element around it.',
142
+ '`text-h2` on `Link` changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in. Use its `variant`, `truncate` or `cover`. A look for what is around it goes on your own element around it.',
143
143
  ],
144
144
  ])
145
145
  })
@@ -115,7 +115,7 @@ tester.run('no-restyled-part', noRestyledPart, {
115
115
  name: 'a look behind a variant, and an extra layer',
116
116
  filename: app,
117
117
  code: ui('Link', ' <Link href="/x" className="signal:hover:bg-success-muted after:absolute [&>*]:shrink-0">x</Link>'),
118
- errors: [{ message: restyled('`signal:hover:bg-success-muted` `after:absolute` `[&>*]:shrink-0`', '`Link`', ' Use its `variant` or `truncate`.') }],
118
+ errors: [{ message: restyled('`signal:hover:bg-success-muted` `after:absolute` `[&>*]:shrink-0`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
119
119
  },
120
120
  {
121
121
  name: 'an inner box’s class prop',
@@ -184,7 +184,7 @@ tester.run('no-restyled-part', noRestyledPart, {
184
184
  name: 'a wrapper declared in the same file',
185
185
  filename: app,
186
186
  code: "import { Link, type LinkProps } from '@estiva-app/ui'\nfunction RouterLink({ href, ...props }: LinkProps) {\n return <Link href={href} {...props} />\n}\nexport function Probe() {\n return <RouterLink href=\"/x\" className=\"text-h5\">x</RouterLink>\n}\n",
187
- errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant` or `truncate`.') }],
187
+ errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant`, `truncate` or `cover`.') }],
188
188
  },
189
189
  {
190
190
  name: 'EmptyState padded directly',
@@ -196,19 +196,19 @@ tester.run('no-restyled-part', noRestyledPart, {
196
196
  name: 'an escape with no reason is reported, and so is the class',
197
197
  filename: app,
198
198
  code: ui('Link', ' // @estiva-escape(no-restyled-part):\n <Link href="/x" className="text-h2">x</Link>'),
199
- errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant` or `truncate`.') }],
199
+ errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
200
200
  },
201
201
  {
202
202
  name: 'inside the package: a part imported from a sibling',
203
203
  filename: pkg,
204
204
  code: local('Link', './Link', ' <Link href="/x" className="rounded-lg border">x</Link>'),
205
- errors: [{ message: restyled('`rounded-lg` `border`', '`Link`', ' Use its `variant` or `truncate`.') }],
205
+ errors: [{ message: restyled('`rounded-lg` `border`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
206
206
  },
207
207
  {
208
208
  name: 'inside the package: a part used in the file that declares it',
209
209
  filename: pkg,
210
210
  code: 'export function Link() {\n return null\n}\nexport function Probe() {\n return <Link className="text-caption" />\n}\n',
211
- errors: [{ message: restyled('`text-caption`', '`Link`', ' Use its `variant` or `truncate`.') }],
211
+ errors: [{ message: restyled('`text-caption`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
212
212
  },
213
213
  ],
214
214
  })
@@ -119,7 +119,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
119
119
  IconButton: ['variant', 'pressed', 'glow'],
120
120
  InputChip: ['truncate'],
121
121
  Kbd: ['tone'],
122
- Link: ['variant', 'truncate'],
122
+ Link: ['variant', 'truncate', 'cover'],
123
123
  MenuItem: ['size', 'selected', 'destructive'],
124
124
  MenuSub: ['selected'],
125
125
  NavItem: ['active'],
@@ -129,6 +129,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
129
129
  RailItem: ['active'],
130
130
  Reaction: ['pressed'],
131
131
  ReactionPicker: ['surface'],
132
+ RichText: ['size'],
132
133
  SectionHeader: ['hover'],
133
134
  SectionLabel: ['tone', 'truncate'],
134
135
  Select: ['size'],
@@ -254,8 +254,29 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
254
254
  return probe(code, 'none', undefined, 'src/components/__gates_probe__.test.tsx', config)()
255
255
  }), 'a test file is not checked for hand-written values, while source is') },
256
256
  ]),
257
+ // Built 28 September: both apps drew the protocol's text tree by hand, and drifted apart.
258
+ // A file that reads the protocol and draws a quote or a numbered list itself is a third copy.
257
259
  ticket('UIG-30', [
258
260
  { what: 'the installed package has RichText', run: () => h.contains(installed, /\bRichText\b/, 'the installed @estiva-app/ui exports RichText') },
261
+ { what: "no file draws the protocol's text tree by hand", run: () => {
262
+ const sources = h.listFiles(at('src'), (n) => /\.tsx$/.test(n) && !/\.(test|stories)\.tsx$/.test(n))
263
+ const own = sources.filter((f) => { const text = h.read(f); return /from ['"]@estiva-app\/protocol['"]/.test(text) && /<(?:blockquote|ol)\b/.test(text) })
264
+ return own.length ? h.FAIL(`draws the text tree itself (${own.join(', ')}): pass the tree to RichText`) : h.PASS('formatted text is drawn by RichText only')
265
+ } },
266
+ ]),
267
+ // UIG-33, Katerina's picks of 28 September: a row that is one link is `Link` with `cover`, and a
268
+ // chip that is a link is `Chip` with `href`. Before, both apps stretched a link over its row by
269
+ // hand (`after:absolute`) and rounded a Link round a chip, each under an escape.
270
+ ticket('UIG-33', [
271
+ { what: 'the installed package has Link cover and Chip href', run: all(h, [
272
+ () => h.contains(at('node_modules/@estiva-app/ui/dist/Link.d.ts'), /\bcover\?: boolean/, 'the installed Link takes cover'),
273
+ () => h.contains(at('node_modules/@estiva-app/ui/dist/Chip.d.ts'), /\bhref\?: string/, 'the installed Chip takes href'),
274
+ ], 'the installed Link takes cover and Chip takes href') },
275
+ { what: 'no link is stretched over its row by hand', run: () => {
276
+ const sources = h.listFiles(at('src'), (n) => /\.tsx$/.test(n) && !/\.(test|stories)\.tsx$/.test(n))
277
+ const own = sources.filter((f) => /<(?:Link|a)\b[^>]*\bafter:(?:absolute|inset-0)\b/.test(h.read(f)))
278
+ return own.length ? h.FAIL(`stretches a link by hand (${own.join(', ')}): give the Link cover`) : h.PASS('every whole-row link is Link with cover')
279
+ } },
259
280
  ]),
260
281
  // Katerina's ruling of 17 September (docs/GATES.md §23): one copy, in the package. An app made
261
282
  // by create-estiva-app passes this from its first commit; Peek and Ship do once UIG-32 lands.
@@ -194,8 +194,13 @@ describe('a made app, under the checks every app runs', () => {
194
194
  const real = helpers(dir)
195
195
  // "The installed package exports Link" reads its declarations, which CI's
196
196
  // test step does not build (`pretest` bundles only): the same exports are read
197
- // from their source when the declarations are not there.
198
- const exportsFrom = (rel: string) => (rel.endsWith('dist/index.d.ts') && !real.exists(rel) ? join(process.cwd(), 'src', 'index.ts') : rel)
197
+ // from their source when the declarations are not there. A part's own props
198
+ // (UIG-33: `Link.d.ts`) are read from its source file the same way.
199
+ const exportsFrom = (rel: string) => {
200
+ const declared = rel.replace(/\\/g, '/').match(/dist\/(\w+)\.d\.ts$/)
201
+ if (!declared || real.exists(rel)) return rel
202
+ return declared[1] === 'index' ? join(process.cwd(), 'src', 'index.ts') : join(process.cwd(), 'src', `${declared[1]}.tsx`)
203
+ }
199
204
  const h: GateHelpers = {
200
205
  ...real,
201
206
  contains: (rel, needle, label) => real.contains(exportsFrom(rel), needle, label),
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  } from './InlineChip'
47
47
  export { Lightbox, type LightboxProps } from './Lightbox'
48
48
  export { Link, type LinkProps, type LinkVariant } from './Link'
49
+ export { RichText, richTextClassName, type RichTextProps, type RichTextBlock, type RichTextRun, type RichTextSize } from './RichText'
49
50
  export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
50
51
  export { Kbd, type KbdProps } from './Kbd'
51
52
  export { IdentityMenu, IdentityPanel, type Identity, type IdentityMenuProps, type IdentityPanelProps } from './IdentityMenu'
@@ -45,17 +45,17 @@ describe('the registry builds', () => {
45
45
  expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
46
46
  })
47
47
 
48
- it('counts 88 names over 58 files: 81 components, 6 helpers and 1 hook (Panel joined on 26 September)', () => {
48
+ it('counts 90 names over 59 files: 82 components, 7 helpers and 1 hook (RichText and richTextClassName joined on 28 September)', () => {
49
49
  // The reconciliation GATES.md §24 explains. If this changes, that changes.
50
50
  const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
51
- expect(kinds).toEqual({ component: 81, helper: 6, hook: 1 })
52
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(58)
51
+ expect(kinds).toEqual({ component: 82, helper: 7, hook: 1 })
52
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(59)
53
53
  })
54
54
 
55
55
  it('gives every entry a purpose, from its own page or from the comment above it', () => {
56
56
  expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
57
- expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(58)
58
- expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(30)
57
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(59)
58
+ expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(31)
59
59
  })
60
60
 
61
61
  it('takes a name with a page of its own from the page, and a name without one from the code', () => {
@@ -87,6 +87,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
87
87
  | A link — in text, on a title, beside a note, or a whole box | **Link** — `text`, `quiet`, `underlined`, `plain`; `external` for a new tab |
88
88
  | A document or an image attached to something, posted or waiting to be sent | **AttachmentCard** — the file decides the shape; `pending` in a composer |
89
89
  | A person or a thing named inside a sentence | **InlineChip** — one line high, level with the words; `href` makes it a link |
90
+ | Formatted text: headings, lists, a quote, code, a table, links | **RichText** — pass the parsed tree; `small` inside a card |
90
91
  | The key that does this too | **Kbd** — or the `shortcut` prop on **MenuItem**, **Tooltip** and **SearchInput** |
91
92
 
92
93
  ## Feedback
@@ -153,6 +153,7 @@ export default {
153
153
  'bg-tooltip': 'var(--bg-tooltip)',
154
154
  'bg-tooltip-key': 'var(--bg-tooltip-key)',
155
155
  'bg-field': 'var(--bg-field)',
156
+ 'bg-code': 'var(--bg-code)',
156
157
  // text
157
158
  'text-primary': 'var(--text-primary)',
158
159
  'text-secondary': 'var(--text-secondary)',