@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
@@ -0,0 +1,91 @@
1
+ import * as React from 'react';
2
+ import type { Completion, Diagnostic, Grammar, HoverInfo } from '../core/types.js';
3
+ import type { ResolvedGrammar } from '../core/scan.js';
4
+ import { LiteArea, type LiteAreaCompletion, type LiteAreaHover, type LiteAreaSizing } from '../dom/editor.js';
5
+ import type { TextSelection } from '../dom/editing.js';
6
+ /** Everything the React component accepts. */
7
+ export interface LiteAreaEditorProps<State = unknown> {
8
+ /**
9
+ * The language.
10
+ *
11
+ * It does NOT have to be stable: the component hands the engine a live view of
12
+ * whatever was passed last, so rebuilding the object on every render is safe and
13
+ * the editor is not rebuilt.
14
+ */
15
+ grammar: Grammar<State> | ResolvedGrammar<State>;
16
+ /**
17
+ * The text to start with. Read ONCE, when the editor mounts.
18
+ *
19
+ * This is the prop to use. A changing initial value is what `value` is for, and it
20
+ * cannot be done by re-rendering, because that is the thing that breaks undo.
21
+ */
22
+ defaultValue?: string;
23
+ /**
24
+ * Text the host wants shown.
25
+ *
26
+ * When this differs from the field it is written — through the browser's editing
27
+ * pipeline, so it stays undoable. Leaving it undefined is the normal case: the
28
+ * field owns the text.
29
+ */
30
+ value?: string;
31
+ /** Whether a `value` write should be one undoable edit. Default `false`. */
32
+ preserveHistory?: boolean;
33
+ /** Whether the user may edit. */
34
+ readOnly?: boolean;
35
+ /** Shown while the box is empty. */
36
+ placeholder?: string;
37
+ /** Whether the browser may spell-check. Default `false`. */
38
+ spellCheck?: boolean;
39
+ /** The accessible name. */
40
+ ariaLabel?: string;
41
+ /** A class on the wrapper element. */
42
+ className?: string;
43
+ /** Styles for the wrapper element. */
44
+ style?: React.CSSProperties;
45
+ /**
46
+ * CSS custom properties for this instance — a theme, a font, or both.
47
+ *
48
+ * Unlike the options read at mount, this one is applied live, and an inline object literal is
49
+ * fine: the records are compared by value, so a fresh `{ font: '…' }` on every render does not
50
+ * mean work on every render.
51
+ */
52
+ variables?: Record<string, string>;
53
+ /** How the box follows its content. Read when the editor mounts. */
54
+ sizing?: LiteAreaSizing;
55
+ /** How the list behaves, or `false` to switch completions off. Read when the editor mounts. */
56
+ completion?: LiteAreaCompletion | false;
57
+ /** How tooltips behave, or `false` to switch them off. Read when the editor mounts. */
58
+ hover?: LiteAreaHover | false;
59
+ /** Whether semantic decorations are painted. Read when the editor mounts. */
60
+ decorations?: boolean;
61
+ /** Whether to inject the stylesheet. Read when the editor mounts. */
62
+ injectStyles?: boolean;
63
+ /** A CSP nonce for the injected stylesheet. */
64
+ styleNonce?: string;
65
+ /** Called after a user edit, with the new text. */
66
+ onChange?: (value: string) => void;
67
+ /** Called when the caret or selection moves. */
68
+ onSelectionChange?: (selection: TextSelection) => void;
69
+ /** Called when the problem list changes. */
70
+ onDiagnostics?: (diagnostics: readonly Diagnostic[]) => void;
71
+ /** Called when the list opens, filters, or closes. */
72
+ onCompletion?: (completion: Completion | undefined) => void;
73
+ /** Called when a tooltip appears or goes away. */
74
+ onHover?: (info: HoverInfo | undefined) => void;
75
+ /**
76
+ * Handed the editor once it exists, and `undefined` when it goes away.
77
+ *
78
+ * The escape hatch for anything the props do not cover: undo and redo, focusing
79
+ * the field, or reading the diagnostics directly.
80
+ */
81
+ editorRef?: (editor: LiteArea<State> | undefined) => void;
82
+ }
83
+ /**
84
+ * A code editor, as a React component.
85
+ *
86
+ * See the module comment for why this does not take the text through React state.
87
+ * @param props - the grammar and the behaviour.
88
+ * @returns the wrapper element the editor mounts into.
89
+ */
90
+ export declare function LiteAreaEditor<State = unknown>(props: LiteAreaEditorProps<State>): React.ReactElement;
91
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/index.tsx"],"names":[],"mappings":"AAoBA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAClF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AACtD,OAAO,EAAE,QAAQ,EAAE,KAAK,kBAAkB,EAAE,KAAK,aAAa,EAAE,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAC7G,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEtD,8CAA8C;AAC9C,MAAM,WAAW,mBAAmB,CAAC,KAAK,GAAG,OAAO;IAClD;;;;;;OAMG;IACH,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG,eAAe,CAAC,KAAK,CAAC,CAAA;IAChD;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,4EAA4E;IAC5E,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,4DAA4D;IAC5D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,2BAA2B;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sCAAsC;IACtC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sCAAsC;IACtC,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAA;IAC3B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAClC,oEAAoE;IACpE,MAAM,CAAC,EAAE,cAAc,CAAA;IACvB,+FAA+F;IAC/F,UAAU,CAAC,EAAE,kBAAkB,GAAG,KAAK,CAAA;IACvC,uFAAuF;IACvF,KAAK,CAAC,EAAE,aAAa,GAAG,KAAK,CAAA;IAC7B,6EAA6E;IAC7E,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,qEAAqE;IACrE,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,+CAA+C;IAC/C,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC,gDAAgD;IAChD,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,aAAa,KAAK,IAAI,CAAA;IACtD,4CAA4C;IAC5C,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,SAAS,UAAU,EAAE,KAAK,IAAI,CAAA;IAC5D,sDAAsD;IACtD,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,GAAG,SAAS,KAAK,IAAI,CAAA;IAC3D,kDAAkD;IAClD,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,GAAG,SAAS,KAAK,IAAI,CAAA;IAC/C;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,GAAG,SAAS,KAAK,IAAI,CAAA;CAC1D;AAqDD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,KAAK,GAAG,OAAO,EAC5C,KAAK,EAAE,mBAAmB,CAAC,KAAK,CAAC,GAChC,KAAK,CAAC,YAAY,CAmFpB"}
@@ -0,0 +1,29 @@
1
+ /** Everything a scope or a mark is painted with comes from one of these. */
2
+ export declare const LITEAREA_STYLES: string;
3
+ /**
4
+ * Put the stylesheet in a document, once.
5
+ *
6
+ * Marked with a data attribute so two editors on one page, or two copies of the
7
+ * library, do not stack two identical sheets — and so a host can find and replace
8
+ * it.
9
+ * @param ownerDocument - the document to install into.
10
+ * @param nonce - an optional CSP nonce for the style element.
11
+ * @returns the style element, or undefined when there is no document.
12
+ */
13
+ export declare function injectStyles(ownerDocument: Document, nonce?: string): HTMLStyleElement | undefined;
14
+ /**
15
+ * The class a scope is painted with.
16
+ *
17
+ * Dots and other punctuation are folded to hyphens, because a scope like
18
+ * `value.shape` is a nice name to write and a terrible one to select: the CSS
19
+ * would need a backslash before the dot at every use, and one forgotten escape
20
+ * silently paints nothing.
21
+ * @param scope - the scope name.
22
+ * @returns the class name.
23
+ */
24
+ export declare function scopeClass(scope: string): string;
25
+ /** The class a decoration kind is painted with. */
26
+ export declare function decorationClass(kind: string): string;
27
+ /** The class a diagnostic severity is underlined with. */
28
+ export declare function severityClass(severity: string): string;
29
+ //# sourceMappingURL=styles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"styles.d.ts","sourceRoot":"","sources":["../../src/styles.ts"],"names":[],"mappings":"AA4FA,4EAA4E;AAC5E,eAAO,MAAM,eAAe,QA+XpB,CAAA;AAER;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,aAAa,EAAE,QAAQ,EACvB,KAAK,CAAC,EAAE,MAAM,GACb,gBAAgB,GAAG,SAAS,CAY9B;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGhD;AAED,mDAAmD;AACnD,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,0DAA0D;AAC1D,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEtD"}
@@ -0,0 +1,316 @@
1
+ # Architecture
2
+
3
+ Why litearea is shaped the way it is. The README says what it does; this says what
4
+ each decision costs and what it was chosen over, because most of them are only
5
+ obvious once you have watched the alternative fail.
6
+
7
+ The tree the editor builds:
8
+
9
+ ```
10
+ div.litearea ← the positioning container, and editor.element
11
+ div.litearea-box ← the border, the radius, and the focus ring
12
+ div.litearea-layer ← the painted text, behind and inert
13
+ textarea ← the real field: transparent text, visible caret
14
+ div.litearea-popup ← the completion list
15
+ div.litearea-tooltip ← the hover tooltip
16
+ ```
17
+
18
+ The layer is inside the box while the two floating elements are outside it,
19
+ because the box clips nothing and a list that had to fit inside a rounded border
20
+ would be cut off at the bottom.
21
+
22
+ ## The one-parse rule
23
+
24
+ `inspect(text, grammar)` produces the tokens, the diagnostics, the decorations, and
25
+ the analysis together, as one value. Everything downstream reads that value and
26
+ nothing re-derives it:
27
+
28
+ | Reader | What it takes from the inspection |
29
+ | --- | --- |
30
+ | The layer | `tokens`, `decorations`, `diagnostics` and the text |
31
+ | The completion list | `tokens`, `diagnostics`, `state`, and the document text |
32
+ | The tooltip | `tokens`, `diagnostics`, `decorations`, `state` |
33
+ | `onDiagnostics` | `diagnostics` |
34
+ | `onAccept` | `state` |
35
+
36
+ The editor caches it on the text: `sync()` re-inspects only when
37
+ `input.value` differs from the text the last inspection was computed from, so
38
+ moving the caret, scrolling, or hovering costs nothing beyond the paint. The paint
39
+ itself carries a key — the revision, whether decorations are painted, and the
40
+ decoration and diagnostic counts — and `Overlay.render` returns immediately when
41
+ both the text and the key match the last call. That is what keeps a caret move
42
+ from rebuilding every span on the page.
43
+
44
+ The failure this prevents is not abstract. The editors this library replaces
45
+ re-derived the tokens, the parse, and the suggestions separately, from the same
46
+ string, in three places, and the three drifted: a word could be painted as a valid
47
+ value while the completer thought it was unknown, and a diagnostic pointed at a
48
+ range that had already moved. Those were not three bugs to fix one at a time; they
49
+ were one missing value. The completion list and the coloured text cannot disagree
50
+ here, because there is only one answer to disagree with.
51
+
52
+ The same idea reaches into the DOM layer: `buildSegments` takes the three range
53
+ lists and produces one stream, `complete` takes an inspection and produces rows,
54
+ and `resolveHover` takes an inspection and produces a tooltip. None of them read
55
+ the document again.
56
+
57
+ ## Why the textarea is uncontrolled
58
+
59
+ A textarea owns its own undo stack, and that stack is maintained by the browser's
60
+ editing pipeline — not by the `value` property. Assigning `field.value` replaces
61
+ the text and destroys the history, and it resets the caret to the end. So the
62
+ editor never writes `value` for an edit the user could have made:
63
+
64
+ ```ts
65
+ field.setSelectionRange(from, to)
66
+ document.execCommand('insertText', false, text) // ← undoable
67
+ ```
68
+
69
+ Three details in that path are all load-bearing:
70
+
71
+ - **The field is focused first**, because the pipeline only runs on the focused
72
+ element, and a completion accepted by mouse click arrives after the click has
73
+ blurred the field.
74
+ - **The return value is not trusted.** It differs across browsers and does not
75
+ need to be trusted: the *value* decides. A change the editor did not make
76
+ itself is a change the browser's history already knows about, which is the whole
77
+ point, so `field.value !== before` is what tells the two paths apart.
78
+ - **Only the changed range is written.** A completion writes `insert` over
79
+ `[from, to)` and then moves the selection with `setSelectionRange`, which
80
+ touches no text and cannot disturb the history.
81
+
82
+ `execCommand` is deprecated and there is no replacement, and that is not an
83
+ oversight anyone can route around. A synthetic `beforeinput` or `InputEvent` is
84
+ untrusted, so the browser refuses to run it through the editing pipeline, and
85
+ `setRangeText` is the standard API that changes the text while bypassing the
86
+ history entirely. Every editor that supports undo on a textarea uses this call.
87
+
88
+ Where it is missing, the edit still happens: `replaceThroughPipeline` falls back
89
+ to `setRangeText`, dispatches the `input` event the browser would have dispatched,
90
+ and returns `'direct'` instead of `'pipeline'`. The caller is told plainly that the
91
+ history did not get the edit, so a host can decide whether that matters.
92
+ `canEditThroughPipeline()` reports which environment you are in, `EditOutcome` is
93
+ `'pipeline' | 'direct' | 'unchanged'`, and `setValue(next, preserveHistory)` is the
94
+ one place that assigns `value` outright — for loading a different document, where
95
+ clearing the history is the right answer rather than a casualty.
96
+
97
+ `undoField` and `redoField` are thin wrappers over `execCommand('undo'/'redo')`.
98
+ They report only whether the call was possible, never whether anything was
99
+ actually undone, because no browser exposes that.
100
+
101
+ ## Layer alignment
102
+
103
+ The painted layer draws the same characters as the field, behind it and inert
104
+ (`aria-hidden`, `pointer-events: none`, `user-select: none`), and the field's own
105
+ text is transparent with a visible caret. Two elements showing one document is
106
+ only stable if they cannot disagree about where a glyph goes, so:
107
+
108
+ **One typography, shared.** `.litearea-layer` and `.litearea-input` share a single
109
+ rule, and the mirror copies the same computed properties. Adding a text property
110
+ to one of them and not the other is the only way to break the stylesheet.
111
+
112
+ **The layer may change three things.** Colour, background, and `text-decoration`.
113
+ Anything that changes an advance — another font, a weight, letter spacing, a font
114
+ feature — slides the paint off the character it belongs to, and it does so by a
115
+ different amount on every character, which reads as a rendering glitch rather than
116
+ as a styling mistake. That is why the box declares one monospace face and refuses
117
+ ligatures: a ligature draws one glyph in the layer where the field draws two, so
118
+ every character after it would be painted in the wrong place.
119
+
120
+ **The scrollbar is compensated, not ignored.** When the box is clamped to its
121
+ maximum height the field grows a scrollbar, and a scrollbar narrows the text. If
122
+ the layer kept the full width it would wrap differently from the field and every
123
+ colour would slide off its character. So the field's measured scrollbar width is
124
+ published as `--litearea-scrollbar` and added to the layer's own right padding —
125
+ and it is read *after* the overflow is applied, because that is when a scrollbar
126
+ exists and therefore when there is a width to report.
127
+
128
+ **Wrapping is not maintained by positioning.** `buildSegments` collects every
129
+ range boundary — every token edge, decoration edge, and diagnostic edge — as a
130
+ cut point, then emits one segment per interval, merging neighbours whose
131
+ presentation is identical. The renderer appends those spans back to back with no
132
+ positioning at all: the characters lay out where the characters lay out, and the
133
+ colours follow. Alignment stops being something the code maintains and becomes
134
+ something the layout cannot get wrong. The traditional alternative is three
135
+ overlay layers, one per range list, and it does not work — each would need its own
136
+ idea of where a range starts, and a span positioned by measuring drifts the moment
137
+ the font, the wrapping, or the padding differs even slightly from what was
138
+ measured.
139
+
140
+ One segment shows one squiggle: the loudest diagnostic covering it wins, because
141
+ two underlines on the same characters only make a messier line. The layer is also
142
+ scrolled by copying the field's `scrollTop`/`scrollLeft` rather than by a
143
+ transform, which is more reliable than a transform that can leave text on a half
144
+ pixel.
145
+
146
+ ## The mirror
147
+
148
+ Two measurements are needed and the field cannot supply either:
149
+
150
+ - **How tall the content is.** A textarea can report `scrollHeight`, but not
151
+ while its own height is being changed: asking for it means collapsing the
152
+ element first, which reflows the page and makes the box flicker on every
153
+ keystroke.
154
+ - **Where the caret is.** A textarea has no API for it at all.
155
+
156
+ So a second element is kept offscreen with the field's exact typography and box,
157
+ and the question is asked of it. One mirror, not two, because both jobs are
158
+ measurements of text with the same copied styles and both are wrong in the same
159
+ way if a single property is missed. It uses `visibility: hidden` rather than
160
+ `display: none`, because a hidden element still lays out and a removed one does
161
+ not.
162
+
163
+ **The property list is deliberately exhaustive**, not "the ones that seemed to
164
+ matter": font family, size, weight, style, stretch, variant ligatures, kerning,
165
+ feature settings, line height, letter spacing, word spacing, text transform,
166
+ indent, align, direction, tab size, white space, overflow wrap, word break,
167
+ hyphens, padding, border widths and styles, and box sizing. A missing
168
+ `letter-spacing` moves the caret a fraction of a character per character, so the
169
+ popup drifts further off the longer the line is — a bug that looks like a
170
+ placement problem and is a copied-style problem.
171
+
172
+ The width is the interesting part. It has to be the field's **content** width or
173
+ text wraps in one and not the other, and the field's `clientWidth` is its content
174
+ plus padding but *not* its border, while the mirror is `border-box`. So the
175
+ borders are added back, and the scrollbar is deliberately left out: a field
176
+ clamped to its maximum height has one, and its text wraps inside the narrower area
177
+ above it.
178
+
179
+ **The line height is measured, not assumed.** `line-height: normal` is a real and
180
+ common value and cannot be parsed as a number, so one line of text is laid out and
181
+ its height taken; a font that reports nothing usable falls back to 1.2 × the font
182
+ size.
183
+
184
+ **The trailing-newline sentinel.** A `white-space: pre-wrap` element whose content
185
+ ends in `\n` does not lay out a final empty line — the newline breaks the line and
186
+ nothing follows it — so the measured height comes back one line short, and the box
187
+ grows a scrollbar exactly when the user presses Enter at the end. A zero-width
188
+ space after the newline gives that last line something to be.
189
+
190
+ **The caret marker technique.** To find the caret, the mirror is rebuilt with the
191
+ text before the caret as a text node, the character *after* the caret inside a
192
+ `white-space: pre` span, and the rest after it. Everything before the caret lays
193
+ out normally, so the marker lands exactly where the next glyph will be, which is
194
+ where the caret is. A span that could wrap would let the marker jump to the next
195
+ line on its own, which is exactly the wrong answer at a line end. At the end of the
196
+ document a zero-width space stands in for the next character. The field's own
197
+ scroll offsets are then subtracted, because the caret's position on screen is what
198
+ the popup is placed against, and a scrolled field moves its text without moving
199
+ its border box. The mirror is rebuilt rather than patched: it is offscreen, so
200
+ measuring it is cheap, while keeping incremental DOM in step with a fast typist is
201
+ not.
202
+
203
+ **The unmounted trap.** An element that is not in the document has no layout, so
204
+ its width is zero, the mirror wraps at every character, and the measurement comes
205
+ back several times too tall. That wrong height then has to be corrected on the
206
+ next keystroke, which is what a box that jumps on first focus actually is. So an
207
+ unmounted field is skipped rather than measured (`!isConnected || clientWidth ===
208
+ 0`), a `ResizeObserver` installed at construction performs the first real
209
+ measurement as soon as there is a width to measure against, and `createEditor`
210
+ re-measures synchronously after mounting so that the very first frame is already
211
+ right. A web font arriving later changes every metric the mirror copied, so
212
+ `document.fonts.ready` triggers one refresh when it lands — without it the editor
213
+ is a few pixels off until something else causes a re-layout.
214
+
215
+ ## Auto-sizing
216
+
217
+ The decision procedure, in the order it runs:
218
+
219
+ 1. With `autoGrow: false`, only adopt the mirror and return. Caret geometry still
220
+ needs the mirror to be shaped like the field even when the height is the host's
221
+ business; the stylesheet gives the field `resize: vertical`.
222
+ 2. `chrome` is the vertical padding plus the vertical borders, which every height
223
+ includes; `lineHeight` comes from the mirror.
224
+ 3. `minPx = max(minHeight ?? 0, lineHeight × minRows + chrome)` — the floor is
225
+ whichever of the two is larger.
226
+ 4. `maxCandidate = min(maxHeight ?? ∞, maxRows ? lineHeight × maxRows + chrome : ∞)`,
227
+ then `maxPx = max(maxCandidate, minPx)`. A maximum below the minimum is a host
228
+ mistake, and honouring it would make the box smaller than the host was told it
229
+ could be.
230
+ 5. `content = mirror.contentHeight(field, text)` — the height the text wants with
231
+ the full width available, and no scrollbar to narrow it.
232
+ 6. `wanted = clamp(max(content, minPx), minPx, maxPx)`; `overflow` becomes `'auto'`
233
+ when the content exceeds what was granted by more than half a pixel, and
234
+ `'hidden'` otherwise.
235
+ 7. Write the height only when it moved by more than half a pixel, write
236
+ `overflow-y` only when it changed, and then read the scrollbar width (now that
237
+ one may exist) and publish it.
238
+
239
+ The mirror has no scrollbar, which is what makes step 5 the number that decides
240
+ whether a scrollbar is needed at all. That is also why **wrapping has to agree
241
+ with the layer**: the decision to clamp is a claim that the text fits in the width
242
+ the layer will be given, so the width the mirror measures at, the width the field
243
+ wraps at, and the width the layer wraps at are the same width — including the
244
+ scrollbar compensation above. `minRows` is additionally written to the textarea's
245
+ native `rows` attribute, so the box has the right height on the very first frame,
246
+ before any measurement, and in a document where the script never runs at all.
247
+
248
+ ## Pure engine, thin DOM
249
+
250
+ `src/core/` imports nothing from `src/dom/`, and nothing in it touches the DOM. Text
251
+ and a grammar go in, values come out:
252
+
253
+ | Testable in plain Node | Needs a browser |
254
+ | --- | --- |
255
+ | `scan` / `inspect`: the paint, the diagnostics, the decorations, the analysis | Whether an edit is undoable |
256
+ | `complete` / `applyCompletion`: eligibility, source stability, the recomputed range, ranking, the edit | Where the caret ends up after an edit |
257
+ | `rank`, `fuzzyMatch`, `highlightSegments` | Whether the layer and the field really line up |
258
+ | `buildSegments`: the merge order | Whether the box has a scrollbar |
259
+ | `resolveHover` and the precedence | Anything about font metrics, wrapping, or scrollbar widths |
260
+ | `defineVocabulary`, `lineStarts`, `wordInfoAt`, `tokenAt` | |
261
+
262
+ Everything in the left column has a decision in it, and a decision is what a unit
263
+ test asserts. Everything in the right column is a fact about a renderer. The four
264
+ of them are exactly why `scripts/browser-check.mjs` exists: a textarea's undo
265
+ stack has no API to read and no way to fake, so the only honest test is to make an
266
+ edit and undo it in a browser that has a real one.
267
+
268
+ The harness bundles the **shipped** `dist/` files rather than `src/` — so what it
269
+ drives is the artifact a host installs — inlines them as an IIFE (Chromium refuses
270
+ to fetch an ES module from a `file://` URL), and drives the public API and the
271
+ public DOM only: it mounts editors, types through `execCommand('insertText')`,
272
+ presses keys, dispatches a `mousedown` on a row, and hovers a squiggle. The page
273
+ reports through `document.title`, with `error` and `unhandledrejection` listeners
274
+ that write to the same channel, because a page whose only output channel is its
275
+ title has to report its own failures through that channel — otherwise a syntax
276
+ error in the bundle shows up as an unchanged title and no evidence at all. The
277
+ browser's stdout is written to files rather than read through a pipe, because a
278
+ browser's stdout cannot be captured through piped stdio in a confined environment
279
+ and comes back empty in a way that looks exactly like the page having produced no
280
+ output.
281
+
282
+ The part of that harness worth copying is the **negative control**. Before it
283
+ tests that a completion can be undone, it asserts that a direct `value` assignment
284
+ is **not** undoable:
285
+
286
+ ```js
287
+ control.value = 'abc'
288
+ control.focus()
289
+ control.value = 'abcd'
290
+ document.execCommand('undo')
291
+ // control.value must still be 'abcd'
292
+ ```
293
+
294
+ Without it, a passing undo test would only show that `execCommand('undo')` returned
295
+ true. The check skips cleanly, with exit 0, when no Chromium-based browser is
296
+ present, so a clean CI runner does not fail for it; `LITEAREA_REQUIRE=1` turns the
297
+ skip into a failure for the environments that must not skip.
298
+
299
+ The DOM layer itself is deliberately thin: `LiteArea` is the only class that holds
300
+ state, and every other module in `src/dom/` is one job — `editing` the
301
+ undo-preserving write, `mirror` the measurement, `overlay` the paint, `popup` the
302
+ list, `tooltip` the hover, `support` the one place that asks the environment a
303
+ question. `createEditor(target, options)` is the class plus "append it and measure
304
+ again", which is all most hosts need.
305
+
306
+ ## What was rejected
307
+
308
+ | Rejected | Why |
309
+ | --- | --- |
310
+ | A controlled component (state → `value` → re-render) | This is the bug the library exists to fix: assigning `value` clears the undo history and resets the caret, and re-rendering text to reposition the caret is a race the browser wins. A grammar worth writing is worth keeping the browser's undo stack for |
311
+ | `contenteditable` | It makes the document a DOM tree that the browser edits in ways you do not control — nested elements, `<br>` for a blank line, pasted HTML — so the value would have to be re-serialised on every keystroke, which is the same re-render problem with more failure modes and no native undo semantics worth having |
312
+ | A second highlighting layer per range list (one for scopes, one for decorations, one for squiggles) | Each layer would have to position its own spans, so each would need its own idea of where a range starts, and a span positioned by measuring drifts as soon as the font, the wrapping, or the padding is even slightly different from what was measured. One merged stream cannot drift because nothing is positioned |
313
+ | Measuring the live field (`scrollHeight` after collapsing it) | It reflows the page and flickers on every keystroke — the measurement that is supposed to decide the height is what makes the height unstable. The offscreen mirror pays one extra layout and keeps the field's geometry out of the question |
314
+ | A bundled grammar (a default language, or a language registry) | The claim of the library is that the core knows no syntax. A default grammar would make that claim false, and it would be the thing every host inherited. The two reference grammars are importable and deliberately outside `src/core/` |
315
+ | Synthetic `beforeinput`/`InputEvent` edits | Untrusted, so the browser will not run them through the editing pipeline: the text changes and the history does not, which is worse than a documented `'direct'` fallback because it fails silently |
316
+ | Tracking the caret as a character index in one module and a screen position in another | Every position that crosses a module boundary is a character offset; pixels enter only in `src/dom/`, only to place a floating element, and never travel back inward. Two coordinate systems for one caret is what produced the range that "had already moved" |