@estiva-app/ui 0.38.0 → 0.40.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
+ }
package/src/Sidebar.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
  import { ScrollArea } from './ScrollArea'
4
4
 
@@ -10,6 +10,10 @@ import { ScrollArea } from './ScrollArea'
10
10
  * What goes inside is the caller's: NavItem rows, a SectionLabel heading
11
11
  * in a 32px row over a group. Desktop only — there is no narrow-screen
12
12
  * drawer.
13
+ *
14
+ * In AppShell's `inset` frame it stands on the ground instead: no hairline,
15
+ * no surface, the content's card beside it (Katerina, 28 September). The
16
+ * frame tells it so; the caller passes nothing.
13
17
  */
14
18
  export interface SidebarProps {
15
19
  /** Names the navigation region for assistive tech. */
@@ -18,11 +22,15 @@ export interface SidebarProps {
18
22
  className?: string
19
23
  }
20
24
 
25
+ /** Set by AppShell's `inset` frame: the sidebar stands on the ground, with no line or surface of its own. */
26
+ export const SidebarOnGround = createContext(false)
27
+
21
28
  export function Sidebar({ 'aria-label': ariaLabel = 'Workspace', children, className }: SidebarProps) {
29
+ const onGround = useContext(SidebarOnGround)
22
30
  return (
23
31
  <nav
24
32
  aria-label={ariaLabel}
25
- className={cn('flex w-60 shrink-0 flex-col border-r border-border-default bg-bg-surface', className)}
33
+ className={cn('flex w-60 shrink-0 flex-col', !onGround && 'border-r border-border-default bg-bg-surface', className)}
26
34
  >
27
35
  {/* The rows scroll in a ScrollArea: the bar takes no width, so the
28
36
  column's padding reads the same with a long list as with a short
package/src/TopBar.mdx CHANGED
@@ -6,10 +6,10 @@ import * as TopBarStories from './TopBar.stories'
6
6
  # TopBar
7
7
 
8
8
  The 52px top bar: `menu` and `logo` on the left, a `search` slot in the
9
- centre, the identity cluster on the right. Two manners — **`solid`**, in
10
- flow with a hairline and a surface, and **`floating`**, an overlay with
11
- neither, whose bar ignores the pointer so the content underneath stays
12
- reachable.
9
+ centre, the identity cluster on the right. Three manners — **`solid`**,
10
+ in flow with a hairline and a surface; **`inset`**, in flow with neither,
11
+ standing on the ground; and **`floating`**, an overlay with neither, whose
12
+ bar ignores the pointer so the content underneath stays reachable.
13
13
 
14
14
  <Canvas of={TopBarStories.Solid} />
15
15
 
@@ -19,6 +19,8 @@ reachable.
19
19
 
20
20
  - **`solid`** — the structured frame's first row, above the sidebar
21
21
  (AppShell's `solid` variant wires exactly this).
22
+ - **`inset`** — the first row of AppShell's `inset` frame, on the ground
23
+ above a sidebar that is on the ground too.
22
24
  - **`floating`** — an app whose content runs to the top edge; the bar
23
25
  floats over it and only its clusters catch clicks (AppShell's
24
26
  `floating` variant).
package/src/TopBar.tsx CHANGED
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
 
4
4
  /**
5
- * The 52px top bar, in two manners:
5
+ * The 52px top bar, in three manners:
6
6
  *
7
7
  * - `solid` — in flow: a hairline under it, the surface behind it, the
8
8
  * frame's first row (2026-09-02, verbatim from the structured app; its
@@ -11,6 +11,9 @@ import { cn } from './cn'
11
11
  * background, and the bar itself ignores the pointer — only its clusters
12
12
  * catch clicks, so the content underneath stays reachable (2026-09-02,
13
13
  * verbatim from the floating app).
14
+ * - `inset` — in flow like `solid`, but standing on the ground: no hairline
15
+ * and no surface, above a sidebar on the ground and the content's card
16
+ * (Katerina, 28 September).
14
17
  *
15
18
  * Geometry both apps agreed on before extraction: 52px tall, `pl-5
16
19
  * pr-[26px]`, right cluster `gap-[6px]`. The left region is two slots
@@ -20,7 +23,7 @@ import { cn } from './cn'
20
23
  * searched; it only knows where those go.
21
24
  */
22
25
  export interface TopBarProps {
23
- variant?: 'solid' | 'floating'
26
+ variant?: 'solid' | 'floating' | 'inset'
24
27
  /** Far left: the menu button, in an app whose navigation collapses. Stays the caller's — the bar only places it. */
25
28
  menu?: ReactNode
26
29
  /** Beside the menu button: the app's logo mark, or its name as text. */
@@ -40,7 +43,7 @@ export function TopBar({ variant = 'solid', menu, logo, search, right, className
40
43
  'flex h-[52px] items-center pl-5 pr-[26px]',
41
44
  floating
42
45
  ? 'pointer-events-none absolute left-0 right-0 top-0 z-10'
43
- : 'shrink-0 gap-4 border-b border-border-default bg-bg-surface',
46
+ : cn('shrink-0 gap-4', variant === 'solid' && 'border-b border-border-default bg-bg-surface'),
44
47
  className,
45
48
  )}
46
49
  >
@@ -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,15 @@ 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
+ } },
259
266
  ]),
260
267
  // Katerina's ruling of 17 September (docs/GATES.md §23): one copy, in the package. An app made
261
268
  // by create-estiva-app passes this from its first commit; Peek and Ship do once UIG-32 lands.
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)',