@estiva-app/ui 0.39.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,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
+ }
@@ -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)',
package/tokens.css CHANGED
@@ -94,6 +94,9 @@
94
94
  --bg-field: #F4F4F5;
95
95
  --border-field: #E4E4E7;
96
96
  --shadow-field: 0 0 #0000;
97
+ /* Code, in a sentence and as a block, and a table's header row (RichText, UIG-30).
98
+ Here, the hover fill: bg-active, #D4D4D8, was the darkest thing on a white card (Katerina, 28 September). */
99
+ --bg-code: #E9E9EB;
97
100
  }
98
101
 
99
102
  /* ─── dark ─── Peek's dark theme (peek/src/index.css .dark, 2026-08-28). */
@@ -172,6 +175,9 @@
172
175
  --bg-field: #27272A;
173
176
  --border-field: #2D2D31;
174
177
  --shadow-field: 0 0 #0000;
178
+ /* Code, in a sentence and as a block, and a table's header row (RichText, UIG-30).
179
+ Here, bg-active, as Peek draws code (Katerina, 28 September). */
180
+ --bg-code: #2D2D31;
175
181
  }
176
182
 
177
183
 
@@ -254,6 +260,9 @@
254
260
  --bg-field: #20242d;
255
261
  --border-field: rgba(255, 255, 255, 0.12);
256
262
  --shadow-field: 0 0 #0000;
263
+ /* Code, in a sentence and as a block, and a table's header row (RichText, UIG-30).
264
+ Here, bg-active, as Peek draws code (Katerina, 28 September). */
265
+ --bg-code: rgba(255, 255, 255, 0.08);
257
266
  }
258
267
 
259
268
  /* ─── ship ─── Ship's palette (Katerina's ruling, 2026-08-28; ship/web/src/index.css). */
@@ -342,6 +351,9 @@
342
351
  --bg-field: #1f2330;
343
352
  --border-field: rgba(255, 255, 255, 0.12);
344
353
  --shadow-field: 0 0 #0000;
354
+ /* Code, in a sentence and as a block, and a table's header row (RichText, UIG-30).
355
+ Here, bg-active, as Peek draws code (Katerina, 28 September). */
356
+ --bg-code: rgba(255, 255, 255, 0.08);
345
357
  }
346
358
 
347
359
  /* ─── leaf ─── Leaf's theme: Linear's light theme on these same tokens (Katerina,
@@ -430,4 +442,7 @@
430
442
  --bg-field: #ffffff;
431
443
  --border-field: #dedede; /* the default line, so a field shows on a white dialog (Katerina, 28 September); Linear's own is #f7f7f7 */
432
444
  --shadow-field: 0 3px 6px -2px rgba(0, 0, 0, 0.02), 0 1px 1px 0 rgba(0, 0, 0, 0.04);
445
+ /* Code, in a sentence and as a block, and a table's header row (RichText, UIG-30).
446
+ Here, the inset fill: lighter than bg-active on a white card (Katerina, 28 September). */
447
+ --bg-code: #e9e9ea;
433
448
  }