@estiva-app/ui 0.63.0 → 0.65.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.63.0",
3
+ "version": "0.65.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -91,7 +91,8 @@
91
91
  "@tiptap/extension-mention": "^3.22.1",
92
92
  "@tiptap/pm": "^3.22.1",
93
93
  "@tiptap/react": "^3.22.1",
94
- "@tiptap/suggestion": "^3.22.1"
94
+ "@tiptap/suggestion": "^3.22.1",
95
+ "@tiptap/extension-code-block-lowlight": "^3.22.1"
95
96
  },
96
97
  "peerDependenciesMeta": {
97
98
  "eslint": {
@@ -117,6 +118,9 @@
117
118
  },
118
119
  "@tiptap/suggestion": {
119
120
  "optional": true
121
+ },
122
+ "@tiptap/extension-code-block-lowlight": {
123
+ "optional": true
120
124
  }
121
125
  },
122
126
  "devDependencies": {
@@ -130,6 +134,7 @@
130
134
  "@testing-library/react": "^16.3.3",
131
135
  "@testing-library/user-event": "^14.6.7",
132
136
  "@tiptap/core": "^3.31.3",
137
+ "@tiptap/extension-code-block-lowlight": "^3.31.3",
133
138
  "@tiptap/extension-mention": "^3.31.3",
134
139
  "@tiptap/pm": "^3.31.3",
135
140
  "@tiptap/react": "^3.31.3",
@@ -162,6 +167,7 @@
162
167
  "dependencies": {
163
168
  "@base-ui/react": "^1.8.0",
164
169
  "clsx": "^2.1.1",
170
+ "lowlight": "^3.3.0",
165
171
  "tailwind-merge": "^3.5.0"
166
172
  },
167
173
  "main": "./dist/index.js",
package/registry.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.63.0",
8
- "exports": 139,
7
+ "packageVersion": "0.65.0",
8
+ "exports": 140,
9
9
  "typeExports": 145,
10
10
  "files": null
11
11
  },
@@ -1196,6 +1196,25 @@
1196
1196
  "app": null,
1197
1197
  "looks": []
1198
1198
  },
1199
+ {
1200
+ "name": "CodeBlockColours",
1201
+ "repo": "estiva-ui",
1202
+ "kind": "component",
1203
+ "importPath": "@estiva-app/ui/editor",
1204
+ "sourceFile": "src/CodeBlockColours.ts",
1205
+ "purpose": "A code block in an editor, coloured by its language as you type.",
1206
+ "purposeFrom": "page",
1207
+ "props": [],
1208
+ "variants": [],
1209
+ "ownsBehaviours": [],
1210
+ "status": "stable",
1211
+ "migrationStage": null,
1212
+ "docsId": "primitives-codeblockcolours--docs",
1213
+ "storyId": "primitives-codeblockcolours--in-an-editor",
1214
+ "docPage": "src/CodeBlockColours.mdx",
1215
+ "app": null,
1216
+ "looks": []
1217
+ },
1199
1218
  {
1200
1219
  "name": "CollapsibleSection",
1201
1220
  "repo": "estiva-ui",
@@ -3439,10 +3458,10 @@
3439
3458
  "docPage": "src/Link.mdx",
3440
3459
  "app": null,
3441
3460
  "looks": [
3442
- "text-info-default underline underline-offset-2 hover:opacity-80",
3443
- "text-info-default underline underline-offset-2 hover:opacity-80 after:rounded-md focus-visible:outline-none",
3444
- "text-info-default underline underline-offset-2 hover:opacity-80 truncate",
3445
- "text-info-default underline underline-offset-2 hover:opacity-80 truncate after:rounded-md focus-visible:outline-none",
3461
+ "text-text-interactive underline underline-offset-2 hover:opacity-80",
3462
+ "text-text-interactive underline underline-offset-2 hover:opacity-80 after:rounded-md focus-visible:outline-none",
3463
+ "text-text-interactive underline underline-offset-2 hover:opacity-80 truncate",
3464
+ "text-text-interactive underline underline-offset-2 hover:opacity-80 truncate after:rounded-md focus-visible:outline-none",
3446
3465
  "hover:underline truncate after:rounded-md focus-visible:outline-none",
3447
3466
  "underline underline-offset-2 hover:text-text-primary after:rounded-md focus-visible:outline-none",
3448
3467
  "underline underline-offset-2 hover:text-text-primary truncate",
@@ -5394,15 +5413,15 @@
5394
5413
  "docPage": "src/RichText.mdx",
5395
5414
  "app": null,
5396
5415
  "looks": [
5397
- "[&>: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",
5398
- "[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-muted [&_ol]:list-decimal [&_ul]:list-disc",
5399
- "[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-border-strong [&_blockquote]:pl-2.5",
5400
- "[&_: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",
5401
- "[&_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",
5416
+ "[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-secondary [&_ol]:list-decimal [&_ul]:list-disc",
5417
+ "[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-text-muted [&_blockquote]:pl-2.5",
5418
+ "[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:border [&_:not(pre)>code]:border-border-default [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-text-primary",
5419
+ "[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:px-3 [&_pre]:py-2.5 [&_pre]:font-mono [&_pre]:text-[0.85em] [&_pre]:leading-relaxed [&_pre]:text-text-primary",
5420
+ "[&_:is(.hljs-keyword,.hljs-doctag,.hljs-type,.hljs-template-tag,.hljs-template-variable)]:text-syntax-keyword [&_.hljs-title]:text-syntax-function [&_:is(.hljs-number,.hljs-literal,.hljs-attr,.hljs-attribute,.hljs-meta,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id)]:text-syntax-constant [&_:is(.hljs-string,.hljs-regexp)]:text-syntax-string [&_:is(.hljs-built_in,.hljs-symbol)]:text-syntax-variable [&_.hljs-comment]:text-syntax-comment [&_:is(.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo)]:text-syntax-tag",
5402
5421
  "[&_: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",
5403
- "[&_em]:italic [&_strong]:font-semibold [&_u]:underline [&_u]:underline-offset-2",
5404
- "text-body-2 [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4",
5405
- "text-caption leading-snug [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold"
5422
+ "[&_em]:italic [&_strong]:font-semibold [&_strong]:text-text-primary [&_u]:underline [&_u]:decoration-text-muted [&_u]:underline-offset-2",
5423
+ "text-body-2 leading-normal [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4",
5424
+ "text-caption leading-snug [&>:is(h1,h2,h3)]:mb-0.5 [&>:is(h1,h2,h3)]:mt-1.5 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold"
5406
5425
  ]
5407
5426
  },
5408
5427
  {
@@ -0,0 +1,49 @@
1
+ import { Meta, Canvas } from '@storybook/addon-docs/blocks'
2
+ import * as CodeBlockColoursStories from './CodeBlockColours.stories'
3
+
4
+ <Meta of={CodeBlockColoursStories} />
5
+
6
+ # CodeBlockColours
7
+
8
+ A code block in an editor, coloured by its language as you type. The colours
9
+ are the ones RichText reads it in, so writing looks the same as reading.
10
+
11
+ <Canvas of={CodeBlockColoursStories.InAnEditor} />
12
+
13
+ ## When
14
+
15
+ - **Any editor that writes RichText**: a message box, an edit box, a
16
+ description. Use it in place of the starter kit's code block.
17
+
18
+ ## When not
19
+
20
+ - Drawing text that is already written → **RichText**, which colours its code
21
+ blocks itself.
22
+ - Code in a sentence → the code mark. It takes no colours.
23
+
24
+ ## How
25
+
26
+ ```tsx
27
+ import StarterKit from '@tiptap/starter-kit'
28
+ import { CodeBlockColours } from '@estiva-app/ui/editor'
29
+ import { richTextClassName } from '@estiva-app/ui'
30
+
31
+ useEditor({
32
+ extensions: [StarterKit.configure({ codeBlock: false }), CodeBlockColours],
33
+ editorProps: { attributes: { class: richTextClassName() } },
34
+ })
35
+ ```
36
+
37
+ - **The editor's root must wear `richTextClassName`.** The colours live in its
38
+ class list; without it the words are named but drawn in one colour.
39
+ - The block keeps the name `codeBlock` and its `language`, so text written
40
+ before it was added reads the same.
41
+ - It colours highlight.js's 37 everyday languages, only by the language the
42
+ block names (```` ```ts ```` then Enter). A block in any other language, or
43
+ in none, stays one colour: it never guesses, so it looks the same once read.
44
+ - The app installs `@tiptap/extension-code-block-lowlight`, as it installs the
45
+ other Tiptap parts.
46
+
47
+ ## What it owns
48
+
49
+ Nothing. It names each word of a code block; the RichText class list colours them.
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { EditorContent, useEditor } from '@tiptap/react'
3
+ import StarterKit from '@tiptap/starter-kit'
4
+ import { Card } from './Card'
5
+ import { CodeBlockColours } from './CodeBlockColours'
6
+ import { richTextClassName } from './RichText'
7
+
8
+ /**
9
+ * Type in the code block: each word takes its colour as you write, the same
10
+ * colours RichText reads it in. Change the theme to see each set.
11
+ */
12
+ const meta = {
13
+ title: 'Primitives/CodeBlockColours',
14
+ parameters: { layout: 'padded' },
15
+ } satisfies Meta
16
+
17
+ export default meta
18
+ type Story = StoryObj
19
+
20
+ function Box() {
21
+ const editor = useEditor({
22
+ extensions: [StarterKit.configure({ codeBlock: false }), CodeBlockColours],
23
+ content: [
24
+ '<p>A code block in a language takes its colours:</p>',
25
+ '<pre><code class="language-ts">// Count the items that are done\nexport function countDone(items: Item[]): number {\n const done = items.filter((item) =&gt; item.state === \'done\')\n return done.length + 0\n}</code></pre>',
26
+ ].join(''),
27
+ editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
28
+ })
29
+ return (
30
+ <Card fill="surface" className="w-full max-w-lg p-3">
31
+ <EditorContent editor={editor} />
32
+ </Card>
33
+ )
34
+ }
35
+
36
+ /** An editor with a code block in TypeScript. Type in it. */
37
+ export const InAnEditor: Story = { render: () => <Box /> }
@@ -0,0 +1,36 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * CodeBlockColours colours a block only by the language it names, as RichText
4
+ * does when the text is read (Katerina, 9 October: never guess).
5
+ */
6
+ import { afterEach, describe, expect, it } from 'vitest'
7
+ import { Editor } from '@tiptap/core'
8
+ import StarterKit from '@tiptap/starter-kit'
9
+ import { CodeBlockColours } from './CodeBlockColours'
10
+
11
+ let editor: Editor | undefined
12
+ afterEach(() => editor?.destroy())
13
+
14
+ const coloured = (content: string) => {
15
+ editor = new Editor({ element: document.createElement('div'), extensions: [StarterKit.configure({ codeBlock: false }), CodeBlockColours], content })
16
+ return editor.view.dom.querySelectorAll('pre [class*="hljs-"]').length
17
+ }
18
+
19
+ describe('CodeBlockColours', () => {
20
+ it('colours a block that names its language', () => {
21
+ expect(coloured('<pre><code class="language-ts">const done = items.length</code></pre>')).toBeGreaterThan(0)
22
+ })
23
+
24
+ it('leaves a block that names no language in one colour, as RichText reads it', () => {
25
+ expect(coloured('<pre><code>const done = items.length</code></pre>')).toBe(0)
26
+ })
27
+
28
+ it('leaves a block in a language it does not know in one colour', () => {
29
+ expect(coloured('<pre><code class="language-nothing">const done = items.length</code></pre>')).toBe(0)
30
+ })
31
+
32
+ it('keeps the block a code block, with its language', () => {
33
+ coloured('<pre><code class="language-ts">const done = 1</code></pre>')
34
+ expect(editor!.getJSON().content?.[0]).toMatchObject({ type: 'codeBlock', attrs: { language: 'ts' } })
35
+ })
36
+ })
@@ -0,0 +1,28 @@
1
+ /** The editor's code block, coloured by language like RichText's (`@estiva-app/ui/editor`). */
2
+ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
3
+ import { lowlight } from './codeColours'
4
+
5
+ /*
6
+ The editor library guesses a language for a block that names none, or names
7
+ one it does not know, and colours it by the guess. RichText never guesses, so
8
+ that block was coloured while written and plain once read. A block is
9
+ coloured only by the language it names, while written and when read
10
+ (Katerina, 9 October: never guess).
11
+ */
12
+ const namedOnly = {
13
+ highlight: (language: string, value: string) => lowlight().highlight(language, value),
14
+ highlightAuto: (value: string) => ({ type: 'root' as const, children: [{ type: 'text' as const, value }], data: { language: undefined, relevance: 0 } }),
15
+ listLanguages: () => lowlight().listLanguages(),
16
+ registered: (language: string) => lowlight().registered(language),
17
+ }
18
+
19
+ /**
20
+ * A code block in the editor, with the colours RichText reads it in (Katerina,
21
+ * 9 October): the same highlight.js languages, the same classes, and the
22
+ * editor's root wears `richTextClassName`, which colours them.
23
+ *
24
+ * It replaces the starter kit's code block: `StarterKit.configure({ codeBlock: false })`,
25
+ * then add `CodeBlockColours`. The block keeps its name, `codeBlock`, and its
26
+ * `language` attribute, so nothing already written changes.
27
+ */
28
+ export const CodeBlockColours = CodeBlockLowlight.configure({ lowlight: namedOnly })
package/src/Link.mdx CHANGED
@@ -14,7 +14,7 @@ the app decides how it navigates.
14
14
 
15
15
  - **It takes the size and colour of the text around it**: put it inside that
16
16
  text.
17
- - **`text`** — a link written inside text: the info colour, always
17
+ - **`text`** — a link written inside text: the theme's link colour, always
18
18
  underlined, dimming on hover.
19
19
  - **`quiet`** — a title or a time that is also a link. It takes the colour and
20
20
  size of the text it sits in, and underlines on hover.
@@ -15,7 +15,7 @@ const meta = {
15
15
  export default meta
16
16
  type Story = StoryObj<typeof meta>
17
17
 
18
- /** Inside written text: the info colour, always underlined. Hover dims it. */
18
+ /** Inside written text: the theme's link colour, always underlined. Hover dims it. */
19
19
  export const Text: Story = {
20
20
  render: (args) => (
21
21
  <p className="max-w-[480px] text-body-2 text-text-primary">
package/src/Link.test.tsx CHANGED
@@ -53,10 +53,10 @@ describe('Link', () => {
53
53
  expect(screen.getByRole('link', { name: 'Documents' }).getAttribute('href')).toBe('/documents')
54
54
  })
55
55
 
56
- it('text is the info colour, always underlined, and is the default', () => {
56
+ it('text is the link colour, always underlined, and is the default', () => {
57
57
  render(<Link href="#">Label</Link>)
58
58
  const classes = classesOf(screen.getByRole('link'))
59
- expect(classes).toContain('text-info-default')
59
+ expect(classes).toContain('text-text-interactive')
60
60
  expect(classes).toContain('underline')
61
61
  expect(classes).toContain('underline-offset-2')
62
62
  })
package/src/Link.tsx CHANGED
@@ -9,7 +9,7 @@ import { cn } from './cn'
9
9
  * on 13 September 2026, in three jobs (UIG-27). The looks are the ones those
10
10
  * links already had, reduced by Katerina's ruling:
11
11
  *
12
- * - `text` — a link inside written text: the info colour, always underlined,
12
+ * - `text` — a link inside written text: the theme's link colour, always underlined,
13
13
  * dimming on hover. The look a link in a body of text already had.
14
14
  * - `quiet` — a title or a timestamp that is also a link. It takes the colour
15
15
  * and size of the text it sits in and underlines on hover. Two looks that
@@ -43,7 +43,7 @@ export type LinkVariant = 'text' | 'quiet' | 'underlined' | 'plain'
43
43
  const COVER_CLASSES = 'after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:[outline-style:auto]'
44
44
 
45
45
  const VARIANT_CLASSES: Record<LinkVariant, string> = {
46
- text: 'text-info-default underline underline-offset-2 hover:opacity-80',
46
+ text: 'text-text-interactive underline underline-offset-2 hover:opacity-80',
47
47
  quiet: 'hover:underline',
48
48
  underlined: 'underline underline-offset-2 hover:text-text-primary',
49
49
  plain: '',
@@ -57,6 +57,7 @@ export const Links: Story = {
57
57
  blocks: [
58
58
  p(t('A link with its own words: [the guide](https://example.com/guide). A bare address: https://example.com/a/long/address/that/breaks/where/it/must.')),
59
59
  p(t('Inside code it stays text: '), t('[not a link](https://example.com)', 'code')),
60
+ p(t('An '), t('underlined', 'underline'), t(' word beside [a link](https://example.com), and '), t('item.count', 'code'), t(' in a sentence.')),
60
61
  ],
61
62
  },
62
63
  }
@@ -89,3 +90,26 @@ export const UnknownBlock: Story = {
89
90
  blocks: [p(t('The block below is of a kind this part does not know.')), { type: 'unknown', typeName: 'later', inline: [t('Its words are still here.')] }],
90
91
  },
91
92
  }
93
+
94
+ /** A code block in a named language. */
95
+ export const CodeInALanguage: Story = {
96
+ args: {
97
+ blocks: [
98
+ {
99
+ type: 'codeBlock',
100
+ language: 'ts',
101
+ inline: [
102
+ t(
103
+ [
104
+ '// Count the items that are done',
105
+ 'export function countDone(items: Item[]): number {',
106
+ " const done = items.filter((item) => item.state === 'done')",
107
+ ' return done.length + 0',
108
+ '}',
109
+ ].join('\n'),
110
+ ),
111
+ ],
112
+ },
113
+ ],
114
+ },
115
+ }
package/src/RichText.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { MouseEvent, ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
  import { Link } from './Link'
4
+ import { codeColours } from './codeColours'
4
5
 
5
6
  /**
6
7
  * Formatted text, drawn: headings, paragraphs, lists, a quote, code, a divider,
@@ -11,7 +12,8 @@ import { Link } from './Link'
11
12
  * and the two had drifted: headings, list markers, quotes and code differed,
12
13
  * one app had tables and the other dropped them, one had links and the other
13
14
  * 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
+ * 28 September, one per difference, most of them Peek's, with the room and
16
+ * colours she changed on 9 October.
15
17
  *
16
18
  * **It takes the tree, not the text.** The caller parses (the format comes from
17
19
  * the item's tag, never from the body) and hands the blocks in. So this package
@@ -84,21 +86,32 @@ export interface RichTextProps {
84
86
  * paragraph inside each list item, quote and table cell (the `> p` lines).
85
87
  */
86
88
  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',
89
+ /* Blocks 8px apart (Katerina, 9 October; 4 before). A list's own items stay together. */
90
+ base: 'flex flex-col gap-2 break-words text-text-secondary',
91
+ /* A heading binds to what follows it: 12px above (4 and the 8px gap), 8px below (the gap); two headings in a row, 12px apart. The same as before the gap grew (Katerina, 9 October: headings do not change). Level 3 keeps the text's colour, one step lower: at the same size as level 2, colour is what tells them apart (Katerina, 9 October). */
92
+ headings: '[&>:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2,h3):first-child]:mt-0 [&>:is(h1,h2)]:text-text-primary',
93
+ /* Bullets and numbers in the text's own colour, so a number can be read (Katerina, 9 October; they were the muted grey, 2.4 : 1 in Leaf). */
94
+ lists: '[&_:is(ul,ol)]:pl-5 [&_li>p]:m-0 [&_li]:break-words [&_li]:marker:text-text-secondary [&_ol]:list-decimal [&_ul]:list-disc',
95
+ /* The line in the muted text grey, not the border grey: a border grey was 1.3 : 1 in Leaf (Katerina, 9 October). */
91
96
  // 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',
97
+ quote: '[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-text-muted [&_blockquote]:pl-2.5',
98
+ /* Code in a sentence: the code fill and a thin border, in the heading colour (Katerina, 9 October: option B; the amber of 0.64.0 was too much). */
99
+ // eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.85 of the text around it, so it scales with a heading and with `small`; a named size is fixed
100
+ code: '[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:border [&_:not(pre)>code]:border-border-default [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-text-primary',
101
+ /* 12px either side, 10px above and below, looser lines than the text (Katerina, 9 October; 8px all round before). */
102
+ // eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.85 of the text around it; the code inside the block is the block's own size, not 0.85 of it again
103
+ codeBlock: '[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:px-3 [&_pre]:py-2.5 [&_pre]:font-mono [&_pre]:text-[0.85em] [&_pre]:leading-relaxed [&_pre]:text-text-primary',
104
+ /* Colours inside a code block, by language: the classes `codeColours` gives each word, after highlight.js's own GitHub theme (Katerina, 9 October). */
105
+ syntax: '[&_:is(.hljs-keyword,.hljs-doctag,.hljs-type,.hljs-template-tag,.hljs-template-variable)]:text-syntax-keyword [&_.hljs-title]:text-syntax-function [&_:is(.hljs-number,.hljs-literal,.hljs-attr,.hljs-attribute,.hljs-meta,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id)]:text-syntax-constant [&_:is(.hljs-string,.hljs-regexp)]:text-syntax-string [&_:is(.hljs-built_in,.hljs-symbol)]:text-syntax-variable [&_.hljs-comment]:text-syntax-comment [&_:is(.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo)]:text-syntax-tag',
106
+ /* A divider splits the text in two: 16px either side (Katerina, 9 October; 4 before). */
107
+ divider: '[&_hr]:my-2 [&_hr]:border-border-default',
98
108
  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',
109
+ /* Bold in the heading colour, not only heavier. An underlined word draws a faint line, so it does not read as a link (Katerina, 9 October). */
110
+ marks: '[&_em]:italic [&_strong]:font-semibold [&_strong]:text-text-primary [&_u]:underline [&_u]:decoration-text-muted [&_u]:underline-offset-2',
111
+ /* Lines 1.5 apart (Katerina, 9 October; 1.4 before). */
112
+ default: 'text-body-2 leading-normal [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4',
113
+ /* Blocks 6px apart; a heading keeps its 12px above and 8px below. */
114
+ small: 'gap-1.5 text-caption leading-snug [&>:is(h1,h2,h3)]:mb-0.5 [&>:is(h1,h2,h3)]:mt-1.5 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold',
102
115
  }
103
116
 
104
117
  /**
@@ -106,8 +119,8 @@ const RICH_TEXT_CLASSES = {
106
119
  * being written looks the same as what is read. Put it on the editor's root.
107
120
  */
108
121
  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)
122
+ const { base, headings, lists, quote, code, codeBlock, syntax, divider, table, marks } = RICH_TEXT_CLASSES
123
+ return cn(base, headings, lists, quote, code, codeBlock, syntax, divider, table, marks, RICH_TEXT_CLASSES[size], className)
111
124
  }
112
125
 
113
126
  /**
@@ -251,9 +264,10 @@ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode
251
264
  return <blockquote data-block-id={block.id}>{runs}</blockquote>
252
265
  case 'codeBlock':
253
266
  // Literal: no marks, no links, no chips. A long line wraps; nothing scrolls sideways.
267
+ // In a language highlight.js knows, each word takes its colour; in any other, the text as it is.
254
268
  return (
255
269
  <pre data-block-id={block.id} data-language={block.language}>
256
- <code>{(block.inline ?? []).map((run) => run.text).join('')}</code>
270
+ <code>{codeColours((block.inline ?? []).map((run) => run.text).join(''), block.language)}</code>
257
271
  </pre>
258
272
  )
259
273
  case 'horizontalRule':
@@ -0,0 +1,41 @@
1
+ import { createElement, type ReactNode } from 'react'
2
+ import { common, createLowlight } from 'lowlight'
3
+
4
+ /**
5
+ * Colours inside a code block, by language (Katerina, 9 October).
6
+ *
7
+ * highlight.js reads the code and names each word (a keyword, a string, a
8
+ * comment); RichText's class list gives each name its colour from the theme's
9
+ * `syntax-*` tokens. The same instance colours the editor's code block
10
+ * (`CodeBlockColours`), so what is written looks the same as what is read.
11
+ *
12
+ * `common` is highlight.js's 37 everyday languages. A language outside them, or
13
+ * none, leaves the code in one colour.
14
+ *
15
+ * Made the first time code is coloured, not when the package loads: a call at
16
+ * the top of the file is kept by every app's bundler, so an app that never draws
17
+ * code carried highlight.js too (measured: an app importing only `Link` was
18
+ * 55 KB gzipped).
19
+ */
20
+ let instance: ReturnType<typeof createLowlight> | undefined
21
+ export function lowlight() {
22
+ return (instance ??= createLowlight(common))
23
+ }
24
+
25
+ interface HastNode {
26
+ type: string
27
+ value?: string
28
+ properties?: { className?: string[] }
29
+ children?: HastNode[]
30
+ }
31
+
32
+ function draw(node: HastNode, key: number): ReactNode {
33
+ if (node.type === 'text') return node.value
34
+ return createElement('span', { key, className: node.properties?.className?.join(' ') }, (node.children ?? []).map(draw))
35
+ }
36
+
37
+ /** The code, its words wrapped in highlight.js's classes; the text as it is when the language is unknown. */
38
+ export function codeColours(text: string, language?: string): ReactNode {
39
+ if (!language || !lowlight().registered(language)) return text
40
+ return (lowlight().highlight(language, text).children as HastNode[]).map(draw)
41
+ }
package/src/editor.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
11
11
  export { PastedImages, pastedImages, pastedImageName, type PastedImagesOptions, type ClipboardSource } from './PastedImages'
12
+ export { CodeBlockColours } from './CodeBlockColours'
12
13
  export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
13
14
  export { EditorBlockHandle, blockDropCursor, deleteBlock, moveBlock, duplicateBlock, startBlockDrag, type EditorBlockHandleProps, type BlockTurnInto } from './EditorBlockHandle'
14
15
  export {
package/src/pages.test.ts CHANGED
@@ -41,6 +41,7 @@ const NOTHING = 'Nothing. It only draws. Clicks and keys are yours.'
41
41
  const HOLDS_ONLY: Record<string, string> = {
42
42
  AppProvider: 'Nothing. It draws nothing; it holds two answers for the parts below it.',
43
43
  PastedImages: 'Nothing. It draws nothing; it hands a pasted picture to the app, named.',
44
+ CodeBlockColours: 'Nothing. It names each word of a code block; the RichText class list colours them.',
44
45
  }
45
46
 
46
47
  const dir = new URL('.', import.meta.url)
@@ -26,7 +26,7 @@ import { SCHEMA_VERSION, validateRegistry, type Registry, type RegistryEntry } f
26
26
  const root = process.cwd()
27
27
  const registry = buildRegistry({ root })
28
28
  /** Editor extensions: named like components by the catalogue, and not React components. */
29
- const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode', 'PersonMention', 'UrgentPersonMention', 'CaptionedReference', 'ReferenceTrigger', 'SlashCommands', 'PastedImages']
29
+ const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode', 'PersonMention', 'UrgentPersonMention', 'CaptionedReference', 'ReferenceTrigger', 'SlashCommands', 'PastedImages', 'CodeBlockColours']
30
30
  const entry = (name: string): RegistryEntry => {
31
31
  const found = registry.entries.find((candidate) => candidate.name === name)
32
32
  if (!found) throw new Error(`no entry for ${name}`)
@@ -48,19 +48,19 @@ describe('the registry builds', () => {
48
48
  expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
49
49
  })
50
50
 
51
- it('counts 139 names over 75 files: 106 components, 32 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, ShowMore on 7 October, AppProvider on 8 October, and KindIcon, thingKindOf and PastedImages with its two helpers, moved from Peek, on 8 October)', () => {
51
+ it('counts 140 names over 76 files: 107 components, 32 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, ShowMore on 7 October, AppProvider on 8 October, and KindIcon, thingKindOf and PastedImages with its two helpers, moved from Peek, on 8 October; CodeBlockColours, the coloured code block of the editor, on 9 October)', () => {
52
52
  // The reconciliation GATES.md §24 explains. If this changes, that changes.
53
- // The ten `EDITOR_EXTENSIONS` count as components by their names; they are
53
+ // The `EDITOR_EXTENSIONS` count as components by their names; they are
54
54
  // editor extensions, documented on SelectionToolbar's, BlockNodes' and
55
55
  // ComposerTriggers' pages.
56
56
  const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
57
- expect(kinds).toEqual({ component: 106, helper: 32, hook: 1 })
58
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(75)
57
+ expect(kinds).toEqual({ component: 107, helper: 32, hook: 1 })
58
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(76)
59
59
  })
60
60
 
61
61
  it('gives every entry a purpose, from its own page or from the comment above it', () => {
62
62
  expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
63
- expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(71)
63
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(72)
64
64
  expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(68)
65
65
  })
66
66
 
@@ -97,6 +97,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
97
97
  | A link — in text, on a title, beside a note, or a whole box | **Link** — `text`, `quiet`, `underlined`, `plain`; `external` for a new tab |
98
98
  | A document or an image attached to something, posted or waiting to be sent | **AttachmentCard** — the file decides the shape; `pending` in a composer |
99
99
  | A picture pasted into an editor, attached rather than pasted | **PastedImages** — from `@estiva-app/ui/editor`; the app hands it its upload |
100
+ | A code block in an editor, coloured by its language as you type | **CodeBlockColours** — from `@estiva-app/ui/editor`, in place of the starter kit's code block |
100
101
  | A person or a thing named inside a sentence | **InlineChip** — one line high, level with the words; `href` makes it a link |
101
102
  | What kind of thing a name stands for — a topic, a project, an issue, a file, a message | **KindIcon** — before the name in a row, or inside an **InlineChip**; `thingKindOf` turns an event kind into one |
102
103
  | Formatted text: headings, lists, a quote, code, a table, links | **RichText** — pass the parsed tree; `small` inside a card |
@@ -155,6 +155,13 @@ export default {
155
155
  'bg-tooltip-key': 'var(--bg-tooltip-key)',
156
156
  'bg-field': 'var(--bg-field)',
157
157
  'bg-code': 'var(--bg-code)',
158
+ 'syntax-keyword': 'var(--syntax-keyword)',
159
+ 'syntax-function': 'var(--syntax-function)',
160
+ 'syntax-constant': 'var(--syntax-constant)',
161
+ 'syntax-string': 'var(--syntax-string)',
162
+ 'syntax-variable': 'var(--syntax-variable)',
163
+ 'syntax-comment': 'var(--syntax-comment)',
164
+ 'syntax-tag': 'var(--syntax-tag)',
158
165
  // text
159
166
  'text-primary': 'var(--text-primary)',
160
167
  'text-secondary': 'var(--text-secondary)',