@citisen/litearea 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/README.zh.md +362 -0
  4. package/dist/grammars.cjs +1228 -0
  5. package/dist/grammars.cjs.map +1 -0
  6. package/dist/grammars.js +1213 -0
  7. package/dist/grammars.js.map +1 -0
  8. package/dist/index.cjs +3103 -0
  9. package/dist/index.cjs.map +1 -0
  10. package/dist/index.js +3040 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/react.cjs +3032 -0
  13. package/dist/react.cjs.map +1 -0
  14. package/dist/react.js +3010 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/styles.cjs +453 -0
  17. package/dist/styles.cjs.map +1 -0
  18. package/dist/styles.css +432 -0
  19. package/dist/styles.js +447 -0
  20. package/dist/styles.js.map +1 -0
  21. package/dist/types/core/complete.d.ts +70 -0
  22. package/dist/types/core/complete.d.ts.map +1 -0
  23. package/dist/types/core/format.d.ts +34 -0
  24. package/dist/types/core/format.d.ts.map +1 -0
  25. package/dist/types/core/grammar.d.ts +14 -0
  26. package/dist/types/core/grammar.d.ts.map +1 -0
  27. package/dist/types/core/hover.d.ts +23 -0
  28. package/dist/types/core/hover.d.ts.map +1 -0
  29. package/dist/types/core/index.d.ts +12 -0
  30. package/dist/types/core/index.d.ts.map +1 -0
  31. package/dist/types/core/inspect.d.ts +34 -0
  32. package/dist/types/core/inspect.d.ts.map +1 -0
  33. package/dist/types/core/rank.d.ts +82 -0
  34. package/dist/types/core/rank.d.ts.map +1 -0
  35. package/dist/types/core/scan.d.ts +51 -0
  36. package/dist/types/core/scan.d.ts.map +1 -0
  37. package/dist/types/core/segments.d.ts +44 -0
  38. package/dist/types/core/segments.d.ts.map +1 -0
  39. package/dist/types/core/text.d.ts +113 -0
  40. package/dist/types/core/text.d.ts.map +1 -0
  41. package/dist/types/core/types.d.ts +604 -0
  42. package/dist/types/core/types.d.ts.map +1 -0
  43. package/dist/types/core/vocabulary.d.ts +82 -0
  44. package/dist/types/core/vocabulary.d.ts.map +1 -0
  45. package/dist/types/dom/create.d.ts +17 -0
  46. package/dist/types/dom/create.d.ts.map +1 -0
  47. package/dist/types/dom/editing.d.ts +89 -0
  48. package/dist/types/dom/editing.d.ts.map +1 -0
  49. package/dist/types/dom/editor.d.ts +366 -0
  50. package/dist/types/dom/editor.d.ts.map +1 -0
  51. package/dist/types/dom/index.d.ts +9 -0
  52. package/dist/types/dom/index.d.ts.map +1 -0
  53. package/dist/types/dom/mirror.d.ts +107 -0
  54. package/dist/types/dom/mirror.d.ts.map +1 -0
  55. package/dist/types/dom/overlay.d.ts +52 -0
  56. package/dist/types/dom/overlay.d.ts.map +1 -0
  57. package/dist/types/dom/popup.d.ts +95 -0
  58. package/dist/types/dom/popup.d.ts.map +1 -0
  59. package/dist/types/dom/support.d.ts +41 -0
  60. package/dist/types/dom/support.d.ts.map +1 -0
  61. package/dist/types/dom/tooltip.d.ts +39 -0
  62. package/dist/types/dom/tooltip.d.ts.map +1 -0
  63. package/dist/types/grammars/dshFont.d.ts +127 -0
  64. package/dist/types/grammars/dshFont.d.ts.map +1 -0
  65. package/dist/types/grammars/dshSentry.d.ts +84 -0
  66. package/dist/types/grammars/dshSentry.d.ts.map +1 -0
  67. package/dist/types/grammars/index.d.ts +3 -0
  68. package/dist/types/grammars/index.d.ts.map +1 -0
  69. package/dist/types/index.d.ts +15 -0
  70. package/dist/types/index.d.ts.map +1 -0
  71. package/dist/types/react/index.d.ts +91 -0
  72. package/dist/types/react/index.d.ts.map +1 -0
  73. package/dist/types/styles.d.ts +29 -0
  74. package/dist/types/styles.d.ts.map +1 -0
  75. package/docs/architecture.md +316 -0
  76. package/docs/completion.md +320 -0
  77. package/docs/grammar.md +823 -0
  78. package/package.json +105 -0
  79. package/scripts/browser-check.mjs +838 -0
  80. package/scripts/build-css.mjs +35 -0
  81. package/scripts/release.mjs +91 -0
  82. package/scripts/verify-package.mjs +253 -0
  83. package/src/core/complete.ts +286 -0
  84. package/src/core/format.ts +71 -0
  85. package/src/core/grammar.ts +40 -0
  86. package/src/core/hover.ts +129 -0
  87. package/src/core/index.ts +98 -0
  88. package/src/core/inspect.ts +198 -0
  89. package/src/core/rank.ts +317 -0
  90. package/src/core/scan.ts +720 -0
  91. package/src/core/segments.ts +185 -0
  92. package/src/core/text.ts +238 -0
  93. package/src/core/types.ts +681 -0
  94. package/src/core/vocabulary.ts +196 -0
  95. package/src/dom/create.ts +31 -0
  96. package/src/dom/editing.ts +213 -0
  97. package/src/dom/editor.ts +1143 -0
  98. package/src/dom/index.ts +46 -0
  99. package/src/dom/mirror.ts +305 -0
  100. package/src/dom/overlay.ts +106 -0
  101. package/src/dom/popup.ts +323 -0
  102. package/src/dom/support.ts +88 -0
  103. package/src/dom/tooltip.ts +112 -0
  104. package/src/grammars/dshFont.ts +1004 -0
  105. package/src/grammars/dshSentry.ts +742 -0
  106. package/src/grammars/index.ts +57 -0
  107. package/src/index.ts +122 -0
  108. package/src/react/index.tsx +248 -0
  109. package/src/styles.ts +529 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 citisen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,514 @@
1
+ # litearea
2
+
3
+ [English](README.md) | [中文](README.zh.md)
4
+
5
+ **A code editor over a plain `<textarea>`: undo and redo that work, highlighting from rules you write, and VSCode-shaped completion — with no runtime dependency.**
6
+
7
+ `@citisen/litearea` is a library, not a plugin. Nothing here registers with an
8
+ interface, reads a settings namespace, or knows what a favicon is. It is a small
9
+ engine that turns a string and a grammar into tokens, diagnostics, decorations,
10
+ and a list of suggestions, plus a thin DOM layer that puts all of that behind a
11
+ real textarea. The React binding is optional and separate.
12
+
13
+ The interesting decision is what the library does *not* do: it does not own the
14
+ text. The textarea owns it, the browser's editing pipeline edits it, and the
15
+ library only ever reads it.
16
+
17
+ ## Why
18
+
19
+ The two DeepSeek Harness plugins this came out of — `dsh-font` and `dsh-sentry` —
20
+ each contain a small DSL, and each grew its own editor over a textarea. Both went
21
+ wrong in the same three ways:
22
+
23
+ - the completion list felt like it was fighting the typist,
24
+ - Ctrl+Z stopped working as soon as a suggestion was accepted,
25
+ - the caret jumped to the end while the user was editing the middle of a line.
26
+
27
+ Those were not three bugs. They were one bug with three faces: the text lived in
28
+ component state, was written back into the textarea on every keystroke, and was
29
+ then read three separate times — once to paint, once to diagnose, once to
30
+ suggest — by three passes that could disagree.
31
+
32
+ Assigning a textarea's `value` from script is not an edit. It replaces the
33
+ element's content, and with it the browser's undo stack, and it resets the
34
+ selection to the end. That single call is why undo was gone *and* why the caret
35
+ moved. The third symptom is the three passes: a word could be painted as a valid
36
+ value while the completer called it unknown and the squiggle pointed at a range
37
+ that had already moved.
38
+
39
+ So this library makes the opposite choices, and each one is load-bearing:
40
+
41
+ - **Undo and redo survive, because the DOM owns the text.** The field is written
42
+ once, at construction, before any listener exists. Every edit the library makes
43
+ on the user's behalf goes through `document.execCommand('insertText')`, so the
44
+ browser records it in its own undo stack — one edit, undoable with the
45
+ browser's own Ctrl+Z. There is deliberately no replacement API: a synthetic
46
+ `input` event is untrusted and the browser refuses to run it through the
47
+ editing pipeline, and `setRangeText` changes the text while bypassing the
48
+ history.
49
+ - **The caret does not move, because nothing rewrites the text under it.** The
50
+ layer reads the field; it never writes it. The only selection writes are
51
+ `setSelectionRange` calls a completion asked for.
52
+ - **One parse per text.** `inspect(text, grammar)` runs once, and the paint, the
53
+ squiggles, the semantic marks, the completion list, and the hover tooltip all
54
+ read that same value. They cannot drift because there is nothing to drift from.
55
+
56
+ ## Install
57
+
58
+ ```sh
59
+ npm install @citisen/litearea
60
+ ```
61
+
62
+ Three entry points and one stylesheet:
63
+
64
+ | Import | What it is |
65
+ | --- | --- |
66
+ | `@citisen/litearea` | The pure engine plus the DOM layer (`createEditor`, `LiteArea`) |
67
+ | `@citisen/litearea/react` | The React binding (`LiteAreaEditor`) |
68
+ | `@citisen/litearea/grammars` | The two worked example grammars |
69
+ | `@citisen/litearea/styles.css` | The same stylesheet the editor injects, for hosts that link CSS |
70
+
71
+ React is an optional peer dependency (`react >= 18`) and the only peer. The
72
+ package has no runtime dependencies at all. The stylesheet is injected into the
73
+ document once, on the first editor, unless `injectStyles: false` is passed.
74
+
75
+ ## Quick start
76
+
77
+ Vanilla, with a grammar small enough to read:
78
+
79
+ ```ts
80
+ import { createEditor, defineGrammar, defineVocabulary } from '@citisen/litearea'
81
+
82
+ // A vocabulary declares the four facts a closed word set needs — the words, the
83
+ // scope they are painted with, the message a non-member earns, and the
84
+ // documentation a hover shows — which is why they cannot drift apart.
85
+ const COLORS = defineVocabulary({
86
+ id: 'color',
87
+ words: ['red', 'green', 'blue'],
88
+ unknownMessage: '"{word}" is not a colour — expected {allowed}.',
89
+ docs: { red: 'The default swatch.' },
90
+ })
91
+
92
+ const grammar = defineGrammar({
93
+ id: 'swatch',
94
+ // Rules are tried in order at every position; the first match wins.
95
+ rules: [
96
+ { kind: 'match', scope: 'comment', pattern: /#[^\n]*/ },
97
+ { kind: 'words', words: COLORS, unknown: {} },
98
+ ],
99
+ compose: [
100
+ {
101
+ id: 'color',
102
+ range: (context) => context.word,
103
+ items: () =>
104
+ ['red', 'green', 'blue'].map((color) => ({ label: color, kind: 'color' })),
105
+ },
106
+ ],
107
+ })
108
+
109
+ const editor = createEditor(document.querySelector('#editor')!, {
110
+ grammar,
111
+ value: 'red # a comment',
112
+ placeholder: 'red, green, blue',
113
+ onChange: (value) => {
114
+ // Reports the user's own edits. A write the library performs — a completion, or
115
+ // setValue below — is marked before it happens and is not reported back.
116
+ console.log(value)
117
+ },
118
+ })
119
+
120
+ // A host-driven write. `true` makes it one undoable edit, so Ctrl+Z brings the
121
+ // previous text back; the default is a plain assignment, which clears the history.
122
+ editor.setValue('green', true)
123
+ ```
124
+
125
+ React, with the same `grammar` object:
126
+
127
+ ```tsx
128
+ import * as React from 'react'
129
+ import type { LiteArea } from '@citisen/litearea'
130
+ import { LiteAreaEditor } from '@citisen/litearea/react'
131
+ import { grammar } from './swatch-grammar'
132
+
133
+ export function SwatchEditor() {
134
+ const [text, setText] = React.useState('')
135
+ const editorRef = React.useRef<LiteArea | null>(null)
136
+
137
+ return (
138
+ <>
139
+ <LiteAreaEditor
140
+ grammar={grammar}
141
+ defaultValue="red # a comment"
142
+ onChange={setText}
143
+ sizing={{ minRows: 2, maxRows: 10 }}
144
+ editorRef={(editor) => {
145
+ editorRef.current = editor ?? null
146
+ }}
147
+ />
148
+ <button type="button" onClick={() => editorRef.current?.undo()}>
149
+ Undo
150
+ </button>
151
+ <pre>{text}</pre>
152
+ </>
153
+ )
154
+ }
155
+ ```
156
+
157
+ `LiteAreaEditor` renders one empty `div` and mounts the imperative editor inside
158
+ it. It never passes the text through React, for the reason in [Why](#why): doing
159
+ so is what breaks undo. `defaultValue` is the prop to use for the starting text;
160
+ `value` exists for a host that wants to write text (a Reset button, loading a
161
+ different document), it is written through the editing pipeline so it stays
162
+ undoable, and it does not call `onChange` — the caller already knows what it wrote.
163
+
164
+ ## What it does
165
+
166
+ | Feature | The mechanism behind it |
167
+ | --- | --- |
168
+ | Undo and redo | The field is written once, at construction. Every library edit goes through `execCommand('insertText')`, so the browser's own history records it |
169
+ | A caret that stays put | Nothing rewrites the text under it; the only selection writes are the ones a completion asked for |
170
+ | Custom highlighting | `rules` paint scopes; a scope becomes the class `litearea-scope-<scope>`, and the stylesheet decides the rest. The core ships no syntax at all |
171
+ | Completion | Sources over a range recomputed from the caret on every filter, tiered fuzzy ranking, a documentation panel, commit characters, and mouse picks that do not blur the field |
172
+ | Diagnostics | A vocabulary's rejection, declarative `checks`, and a `validate` hook, merged, deduplicated, and painted as squiggles in four severities |
173
+ | Hover tooltips | `resolveHover`: a diagnostic outranks everything else; otherwise a decoration's title and the grammar's own `describe` are shown together |
174
+ | Semantic marks | `decorate` returns ranges that are deliberately not tokens, painted as `litearea-dec-<kind>` and recomputed without re-lexing |
175
+ | Auto-sizing | An offscreen mirror is measured instead of the live field; height and overflow are written, and the measured scrollbar width is published for the layer |
176
+ | One parse per text | `inspect` produces tokens, diagnostics, decorations, and the analysis together, cached on the text; the paint is skipped when text and analysis are unchanged |
177
+ | Keyboard and a11y | `role="combobox"`, `aria-expanded`, `aria-activedescendant`, `aria-invalid`, `aria-label`, and a listbox with real row ids |
178
+ | Weight | No runtime dependencies, no CodeMirror, no Monaco, no virtual DOM in the engine |
179
+
180
+ ## Writing a grammar
181
+
182
+ A grammar is one object. This one describes a tiny form schema:
183
+
184
+ ```
185
+ # the signup form
186
+ form signup
187
+ text email required
188
+ text password secret "at least 12 characters"
189
+ number age optional
190
+ ```
191
+
192
+ ```ts
193
+ import { defineGrammar, defineVocabulary } from '@citisen/litearea'
194
+
195
+ const FIELD_TYPES = ['text', 'number', 'bool'] as const
196
+ const OPTION_WORDS = ['required', 'optional', 'secret'] as const
197
+
198
+ const TYPES = defineVocabulary({
199
+ id: 'field-type',
200
+ words: FIELD_TYPES,
201
+ scope: 'field.type',
202
+ unknownMessage: '"{word}" is not a field type — expected {allowed}.',
203
+ docs: {
204
+ text: { detail: 'one line of text', body: 'The only type that can be `secret`.' },
205
+ number: { detail: 'a number' },
206
+ bool: { detail: 'yes or no' },
207
+ },
208
+ })
209
+
210
+ const OPTIONS = defineVocabulary({
211
+ id: 'option',
212
+ words: OPTION_WORDS,
213
+ scope: 'option',
214
+ docs: {
215
+ required: { detail: 'cannot be left empty' },
216
+ optional: { detail: 'may be left empty' },
217
+ secret: { detail: 'never shown again' },
218
+ },
219
+ })
220
+
221
+ export const formSchema = defineGrammar({
222
+ id: 'form-schema',
223
+ name: 'form schema',
224
+ // A name may contain a hyphen, so `email-address` is ONE word: completion
225
+ // replaces the whole name and a double click selects all of it.
226
+ wordChars: /[\p{L}\p{N}_-]/u,
227
+ rules: [
228
+ { kind: 'match', scope: 'comment', pattern: /#[^\n]*/ },
229
+ { kind: 'match', scope: 'keyword', pattern: /form/, when: { prevNot: '\\w' } },
230
+ {
231
+ kind: 'match',
232
+ scope: 'form.name',
233
+ pattern: /[A-Za-z][\w-]*/,
234
+ when: { after: ['keyword'] },
235
+ },
236
+ // A row starts with a field type. Anything else at the head of a line is
237
+ // reported rather than quietly falling through to plain text.
238
+ { kind: 'words', words: TYPES, when: { firstOnLine: true }, unknown: {} },
239
+ { kind: 'words', words: OPTIONS },
240
+ { kind: 'match', scope: 'name', pattern: /[A-Za-z][\w-]*/, when: { after: ['field.type'] } },
241
+ { kind: 'region', scope: 'note', begin: /"/, end: /"/, unclosed: { severity: 'warning' } },
242
+ { kind: 'match', scope: 'invalid', pattern: /\S+/ },
243
+ ],
244
+ fallbackScope: 'text',
245
+ compose: [
246
+ {
247
+ id: 'field-type',
248
+ // `firstWord` rather than `firstOnLine`: the list has to stay eligible while
249
+ // the first word is being spelled, not only when the line is still empty.
250
+ when: (context) => context.firstWord,
251
+ range: (context) => context.word,
252
+ items: () =>
253
+ FIELD_TYPES.map((type) => ({
254
+ label: type,
255
+ append: ' ',
256
+ kind: 'type',
257
+ detail: TYPES.entryFor(type)?.detail,
258
+ documentation: TYPES.entryFor(type)?.body,
259
+ })),
260
+ },
261
+ {
262
+ id: 'option',
263
+ // An option follows a name, and the name may itself still be half-typed.
264
+ when: (context) =>
265
+ context.tokens.some(
266
+ (token) =>
267
+ token.line === context.line.number &&
268
+ token.scope === 'name' &&
269
+ token.to <= context.caret,
270
+ ),
271
+ range: (context) => context.word,
272
+ items: () =>
273
+ OPTION_WORDS.map((word) => ({
274
+ label: word,
275
+ kind: 'option',
276
+ detail: OPTIONS.entryFor(word)?.detail,
277
+ })),
278
+ },
279
+ ],
280
+ describe: (context) => {
281
+ const token = context.token
282
+ if (token === undefined) return undefined
283
+ if (token.scope === 'note') return { title: 'note', body: 'Shown under the field.' }
284
+ const entry =
285
+ token.scope === 'field.type' ? TYPES.entryFor(token.text) : OPTIONS.entryFor(token.text)
286
+ return entry === undefined ? undefined : { title: token.text, body: entry.body }
287
+ },
288
+ })
289
+ ```
290
+
291
+ That is the whole language: eight rules, two vocabularies, two completion
292
+ sources, and a hover. `TYPES` is painted through a vocabulary, so a word in the
293
+ wrong place earns one message instead of being silently plain; `OPTIONS` is a
294
+ plain rule that never rejects, so a rule lower down still gets its turn at a word
295
+ it does not know.
296
+
297
+ The complete reference — every field of every rule kind, precedence, the
298
+ diagnostic vocabulary, and an end-to-end walkthrough that adds `analyze`,
299
+ `checks`, and `validate` to this same language — is in
300
+ [docs/grammar.md](docs/grammar.md).
301
+
302
+ ## The two reference grammars
303
+
304
+ The core ships no syntax: there is no built-in language, no language identifier
305
+ to switch on, and nothing in `src/core/` that knows what a font stack is. The two
306
+ grammars in `@citisen/litearea/grammars` exist to check that claim rather than
307
+ merely assert it. Both are real DSLs from the two plugins this library came out
308
+ of, and both are importable by a host that does not want to retype a language
309
+ that already exists:
310
+
311
+ | Grammar | The language | What it exercises |
312
+ | --- | --- | --- |
313
+ | `dshSentryStyleGrammar()` | dsh-sentry's appearance document: one line per session state, then positional values or `key=value` pairs | A first-word vocabulary with rejection, an analysis that both fills slots and records the problems it found, a declarative `check`, two completion sources using `sortText`, and hover from vocabulary documentation |
314
+ | `dshFontQueryGrammar()` | dsh-font's font query: a CSS font-family list with the weight written beside the family it belongs to | Quote handling in the lexical rules, a `scope` function that reads the analysis, dynamic vocabularies resolved from the host's installed catalogue, multi-word phrases, completion that inserts *before* an entry, and a semantic decoration |
315
+
316
+ Neither is built in. Nothing in `src/core/` imports them, no option switches them
317
+ on, and an editor constructed without one of them has no syntax whatsoever.
318
+
319
+ Two decisions in `dshSentryStyleGrammar` are worth stating, because both were
320
+ found by comparing the grammar against the parser it edits for:
321
+
322
+ - **It is deliberately stricter than the host parser.** `parseStyle` in the
323
+ plugin does no value checking for a known option: it writes `shape=bogus` into
324
+ the rule and lets `resolveLook` substitute the shipped default later, so a typo
325
+ shows up as an icon that simply never changed. The grammar reports it — as a
326
+ warning rather than an error, because the document still works, it just does
327
+ not mean what it says.
328
+ - **It does not accept `fallback` as a state.** The plugin's own module comment
329
+ shows a `fallback none` line, but `STYLE_STATES` holds only the four states and
330
+ `fallback` is derived internally from `STYLE_FALLBACK_LOOK`; it has never been
331
+ parseable. That comment is stale, and copying it into the grammar would have
332
+ made the editor disagree with the parser it edits for.
333
+
334
+ ## Auto-sizing
335
+
336
+ Five options, one behaviour each:
337
+
338
+ | Option | Default | What it does |
339
+ | --- | --- | --- |
340
+ | `autoGrow` | `true` | The height follows the content. With `false` the field gets `resize: vertical` and the host owns the height |
341
+ | `minRows` | `1` | The fewest lines to show. Written to the textarea's native `rows` attribute, so the first frame is already right |
342
+ | `maxRows` | — | The most lines to show before a scrollbar appears |
343
+ | `minHeight` | — | A minimum height in pixels, in addition to `minRows` |
344
+ | `maxHeight` | — | A maximum height in pixels, in addition to `maxRows` |
345
+
346
+ The three behaviours the brief above adds up to:
347
+
348
+ - **It grows and shrinks to fit, and shows no scrollbar while it fits.** When the
349
+ measured content height is under the maximum, that height is written to the
350
+ field and `overflow-y` stays `hidden`.
351
+ - **A maximum introduces a scrollbar.** Past the clamp the height is fixed and
352
+ `overflow-y` becomes `auto`. A scrollbar narrows the text, so the measured
353
+ scrollbar width is published as `--litearea-scrollbar` and added to the layer's
354
+ own padding, or the paint would wrap differently from the field and every
355
+ colour would slide off its character.
356
+ - **A minimum sets the floor**: `minHeight`, or `minRows` worth of line boxes,
357
+ plus the field's vertical padding and border. A maximum below the minimum is
358
+ raised to the minimum rather than honoured, because honouring it would make the
359
+ box smaller than the host said it could be.
360
+
361
+ An unmounted field is not measured at all: before the element is in the document
362
+ it has no layout, so its width is zero and the measurement comes back several
363
+ times too tall. `createEditor` does the first measurement synchronously after
364
+ mounting for exactly this reason, and a host constructing `LiteArea` directly
365
+ should call `refresh()` after appending `editor.element`.
366
+
367
+ ## Keyboard
368
+
369
+ Everything the editor intercepts, and what it deliberately leaves alone:
370
+
371
+ | Key | What it does |
372
+ | --- | --- |
373
+ | `ArrowDown` / `ArrowUp` | Move the active row by one. It stops at the ends instead of wrapping |
374
+ | `PageDown` / `PageUp` | Move the active row by eight |
375
+ | `Enter` | Accept the active row |
376
+ | `Tab` | Accept the active row |
377
+ | `Escape` | Close the list; with no list open, hide the tooltip |
378
+ | `Ctrl+Space` / `Cmd+Space` | Open the list, or close it when it is already open |
379
+ | a row's `commitCharacters` | Accept the active row and write the character after it, so the keystroke is not swallowed. The list then closes |
380
+ | `Ctrl+Z`, `Ctrl+Shift+Z`, `Ctrl+Y` | **Not intercepted.** These are the browser's own undo and redo on the field, which is the whole point of being uncontrolled |
381
+ | `Shift+Arrow`, `Home`, `End` | Not intercepted. They move the caret without an `input` event, so the editor just closes a list the caret has walked out of |
382
+
383
+ ## Theming
384
+
385
+ A scope becomes a class (`litearea-scope-value-color`), a decoration kind becomes
386
+ a class (`litearea-dec-effective`), and a severity becomes a class
387
+ (`litearea-diag-error`). Dots and other punctuation fold to hyphens, so
388
+ `value.color` is selected as `litearea-scope-value-color` and never needs a
389
+ backslash.
390
+
391
+ Colours, spacing, and the type scale come from custom properties on the wrapper:
392
+
393
+ | Property | Default |
394
+ | --- | --- |
395
+ | `--litearea-font` | A monospace stack starting at `ui-monospace` |
396
+ | `--litearea-font-size` | `13px` |
397
+ | `--litearea-line-height` | `20px` |
398
+ | `--litearea-padding-block` / `--litearea-padding-inline` | `6px` / `10px` |
399
+ | `--litearea-radius` | `8px` |
400
+ | `--litearea-fg` / `--litearea-fg-dim` / `--litearea-fg-strong` | `#1f2328` / `#6b7280` / `#111827` |
401
+ | `--litearea-bg` / `--litearea-bg-raised` | `#ffffff` |
402
+ | `--litearea-border` / `--litearea-border-focus` | `#d8dbe0` / `#4d6bfe` |
403
+ | `--litearea-accent` / `--litearea-accent-soft` / `--litearea-selection` | `#4d6bfe` and two alpha variants |
404
+ | `--litearea-error` / `--litearea-warning` / `--litearea-info` / `--litearea-hint` | The four severity colours |
405
+ | `--litearea-shadow` | The floating panel's shadow |
406
+ | `--litearea-scope-*` | One colour per scope the two reference grammars use |
407
+
408
+ A dark scheme is applied automatically from `prefers-color-scheme`.
409
+
410
+ ### A custom theme, or a custom font
411
+
412
+ Both are the same mechanism, and there are two ways to reach it. From CSS, scoped to whatever
413
+ you like:
414
+
415
+ ```css
416
+ .myEditor .litearea {
417
+ --litearea-font: "IBM Plex Mono", ui-monospace, monospace;
418
+ --litearea-font-size: 12px;
419
+ --litearea-line-height: 18px;
420
+ --litearea-scope-comment: #8b919b;
421
+ --litearea-scope-value-color: #0f766e;
422
+ }
423
+ ```
424
+
425
+ Or per instance, without writing a stylesheet rule — which is what the `variables` option is
426
+ for, and the only way to give two editors on one page two different faces:
427
+
428
+ ```ts
429
+ createEditor(target, {
430
+ grammar,
431
+ value: 'running circle blue turn 3',
432
+ variables: {
433
+ font: 'Georgia, serif', // any face: see below
434
+ 'font-size': '15px',
435
+ 'line-height': '26px',
436
+ 'scope-state': '#b91c1c',
437
+ accent: '#c2410c',
438
+ '--my-brand': 'hotpink', // a name that starts with -- is used verbatim
439
+ },
440
+ })
441
+ ```
442
+
443
+ A key may be written `font`, `litearea-font`, or `--litearea-font`. Calling
444
+ `editor.setVariables({ … })` replaces what that method set last time, so a property you drop
445
+ falls back to CSS rather than lingering. In React the same prop is applied live, and an inline
446
+ object literal is fine — the records are compared by value.
447
+
448
+ **Any font works, including a proportional one.** The question a host asks first is whether
449
+ alignment survives a face the editor did not choose, and it does: the painted layer, the real
450
+ textarea, and the offscreen mirror that measures them all read the same computed font, so they
451
+ wrap identically whatever it is. Monospace is the *default*, not a requirement, and a
452
+ proportional face will look odd for code while still being perfectly aligned.
453
+
454
+ What is not yours to change is ligatures and kerning, which are forced off. A ligature draws one
455
+ glyph where the field holds two characters, and the layer splits its spans wherever a diagnostic
456
+ or a decoration begins — so a split would leave one glyph in the field and two in the paint, at
457
+ different widths, and the rest of the line would slide out from under its colour. Turning them
458
+ off removes the possibility rather than hoping no span boundary ever lands inside a pair.
459
+
460
+ Changing those properties is safe because the layer, the field, and the mirror
461
+ all read them. Adding a text property to the layer alone is not: the layer may
462
+ change colour, background, and `text-decoration`, and nothing that moves a glyph.
463
+ `--litearea-scrollbar` is written by the editor; do not set it.
464
+
465
+ ## Limits and honest notes
466
+
467
+ - **`document.execCommand` is deprecated and there is no replacement.** It is
468
+ also the only way to change a textarea's value while keeping the browser's undo
469
+ stack. Where it is missing, an edit still lands through `setRangeText` but the
470
+ history does not get it — `canEditThroughPipeline()` reports which environment
471
+ you are in, and `EditOutcome` tells you per edit whether it was `'pipeline'`,
472
+ `'direct'`, or `'unchanged'`.
473
+ - **The layer's typography is fixed to one monospace face and ligatures are off.**
474
+ A ligature draws one glyph in the layer where the field draws two, so every
475
+ character after it would be painted in the wrong place.
476
+ - **`onChange` reports the user's edits only.** Every write the library performs — a
477
+ completion, or `setValue` in either mode — is marked as its own before it happens,
478
+ so the caller is never handed its own change back. The React `value` prop goes
479
+ through `setValue`, so it is silent too.
480
+ - **`onDiagnostics` fires once when the editor mounts**, even when the document is
481
+ clean, and then only when the problem list really changed — compared on position,
482
+ code, and message. A host waiting to be told its list was clear therefore hears it.
483
+ - **In the React binding, `sizing`, `completion`, `hover`, `decorations`,
484
+ `injectStyles`, and `styleNonce` are read when the editor mounts.** Changing
485
+ one of them later re-renders the wrapper and nothing else. Only `grammar`,
486
+ `value`, and `readOnly` are re-read on every render, and only the grammar is
487
+ safe to rebuild on every render (`refresh()` re-resolves it without rebuilding
488
+ the element, which is what keeps the undo history across a language change).
489
+ - **The painted layer can only change colour, background, and text-decoration.**
490
+ Anything that changes an advance — another font, a weight, letter spacing, a
491
+ font feature — slides the paint off the character it belongs to by a different
492
+ amount on every character.
493
+ - **`inspect` runs the whole scan for each new text.** There is no incremental
494
+ re-lex, so a very large document costs a full pass per keystroke. The paint is
495
+ skipped when the text and the analysis are unchanged, which is what keeps
496
+ caret moves and hovers cheap.
497
+ - **Hover needs a caret hit-test.** It asks
498
+ `caretPositionFromPoint`/`caretRangeFromPoint` where the pointer is;
499
+ `hasCaretHitTest()` reports whether the environment has either. Without one the
500
+ tooltip simply never appears.
501
+ - **Nothing here is a full editor.** There are no line numbers, no search, no
502
+ multiple cursors, no bracket matching, no folding, no snippets, and no undo
503
+ button — the browser's own history is the undo stack, and `editor.undo()` and
504
+ `editor.redo()` are thin wrappers over it that report only whether the call was
505
+ possible.
506
+ - **Hover tooltips and the documentation panel are plain text.** There is no
507
+ markdown rendering and no HTML: a font family called `<b>` is shown as `<b>`.
508
+ - **There is no `'commit'` completion trigger.** A commit character accepts the row,
509
+ writes the character and closes the list; whatever is typed next is an ordinary
510
+ `'auto'` trigger. `CompletionTrigger` is `'auto' | 'explicit'`.
511
+
512
+ ## License
513
+
514
+ MIT