@cplieger/web-terminal-ui 1.0.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/css/MANIFEST ADDED
@@ -0,0 +1,5 @@
1
+ # web-terminal-ui CSS bundle manifest
2
+ # Files concatenated in declared order to produce the served style.css.
3
+ 00-tokens.css
4
+ 01-reset.css
5
+ 02-app.css
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@cplieger/web-terminal-ui",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: textarea input model, mobile key toolbar, context menu, IME, predictive echo, viewport/keyboard-inset handling, and CSS. One mount() call.",
6
+ "license": "GPL-3.0-or-later",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "types": "./src/index.ts",
11
+ "scripts": {
12
+ "typecheck": "tsgo -p tsconfig.json",
13
+ "typecheck:tests": "tsgo -p tsconfig.test.json",
14
+ "test": "vitest --run",
15
+ "test:watch": "vitest",
16
+ "test:coverage": "vitest --run --coverage",
17
+ "lint:eslint": "eslint .",
18
+ "lint:prettier": "prettier --check .",
19
+ "lint:fix": "eslint . --fix && prettier --write .",
20
+ "lint:knip": "knip",
21
+ "verify": "bash scripts/verify.sh"
22
+ },
23
+ "peerDependencies": {
24
+ "@cplieger/web-terminal-engine": ">=1.2.0"
25
+ },
26
+ "devDependencies": {
27
+ "@eslint/js": "10.0.1",
28
+ "@types/node": "25.9.4",
29
+ "@vitest/coverage-v8": "4.1.9",
30
+ "eslint": "10.5.0",
31
+ "fast-check": "4.8.0",
32
+ "happy-dom": "20.10.6",
33
+ "html-validate": "11.5.3",
34
+ "knip": "6.20.0",
35
+ "prettier": "3.8.4",
36
+ "stylelint": "17.13.0",
37
+ "stylelint-config-standard": "40.0.0",
38
+ "typescript": "6.0.3",
39
+ "typescript-eslint": "8.62.0",
40
+ "vitest": "4.1.9"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/cplieger/web-terminal-ui.git"
48
+ },
49
+ "files": [
50
+ "src/",
51
+ "css/",
52
+ "scaffold/",
53
+ "LICENSE",
54
+ "README.md",
55
+ "!src/**/*.test.ts",
56
+ "!src/**/fc-strict-setup.ts"
57
+ ]
58
+ }
@@ -0,0 +1,52 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover,interactive-widget=resizes-content">
6
+ <meta name="color-scheme" content="dark">
7
+ <title>Web Terminal</title>
8
+ <!-- App icons and web-app manifest are deployment-specific; add your own. -->
9
+ <link rel="stylesheet" href="/style.css">
10
+ <!-- [html-validate-disable-next no-style-tag -- critical CSS for instant loading indicator] -->
11
+ <style>
12
+ #loading{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;z-index:200;background:#000;font-family:system-ui,-apple-system,sans-serif;color:#b48eff;font-size:18px;transition:opacity .3s ease}
13
+ #loading.fade{opacity:0}
14
+ #loading .spin{width:32px;height:32px;border:3px solid #b48eff;border-right-color:transparent;border-radius:50%;animation:sp .6s linear infinite}
15
+ @keyframes sp{to{transform:rotate(360deg)}}
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <div id="term" class="term">
20
+ <div id="term-output" class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
21
+ <textarea id="term-input" class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
22
+ <div id="composition-view" class="composition-view" aria-hidden="true"></div>
23
+ <div id="pred-cursor" class="pred-cursor" aria-hidden="true"></div>
24
+ </div>
25
+ <div id="loading"><div class="spin"></div>Loading…</div>
26
+ <div id="conn-banner" class="conn-banner" role="status" aria-live="polite"></div>
27
+ <div id="key-toolbar" class="key-toolbar collapsed no-transition" aria-label="Navigation keys" role="toolbar">
28
+ <button type="button" id="kb-toggle" class="kb-toggle" aria-label="Toggle key toolbar"><svg class="icon-hamburger" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg><svg class="icon-close" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>
29
+ <button type="button" id="kb-tab" class="kb-key kb-r1c1" aria-label="Tab">TAB</button>
30
+ <button type="button" id="kb-esc" class="kb-key kb-r1c2" aria-label="Escape">ESC</button>
31
+ <button type="button" id="kb-up" class="kb-key kb-r1c3" aria-label="Up"><svg viewBox="0 0 24 24"><polyline points="6 15 12 9 18 15"/></svg></button>
32
+ <button type="button" id="kb-enter" class="kb-key kb-r1c4" aria-label="Enter"><svg viewBox="0 0 24 24"><polyline points="9 10 4 15 9 20"/><polyline points="20 4 20 15 4 15"/></svg></button>
33
+ <button type="button" id="scroll-bottom" class="kb-scroll-bottom" aria-label="Scroll to bottom"><svg viewBox="0 0 24 24"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg></button>
34
+ <button type="button" id="kb-ctrl" class="kb-key kb-r2c1" aria-label="Sticky Ctrl modifier" aria-pressed="false">CTRL</button>
35
+ <button type="button" id="kb-left" class="kb-key kb-r2c2" aria-label="Left"><svg viewBox="0 0 24 24"><polyline points="15 6 9 12 15 18"/></svg></button>
36
+ <button type="button" id="kb-down" class="kb-key kb-r2c3" aria-label="Down"><svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></button>
37
+ <button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
38
+ </div>
39
+ <div id="ctx-menu" class="ctx-menu"></div>
40
+ <!-- Deployment-specific: the importmap resolves the bare engine specifier to
41
+ the engine's compiled JS, and /app.js is your bundle that calls
42
+ `mount()` from @cplieger/web-terminal-ui. Adjust paths to your build. -->
43
+ <script type="importmap">
44
+ {
45
+ "imports": {
46
+ "@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js"
47
+ }
48
+ }
49
+ </script>
50
+ <script type="module" src="/app.js"></script>
51
+ </body>
52
+ </html>
@@ -0,0 +1,171 @@
1
+ // IME / composition support.
2
+ //
3
+ // Mirrors xterm.js's CompositionHelper. The browser fires
4
+ // compositionstart → compositionupdate(s) → compositionend when an IME
5
+ // (Japanese / Korean / Chinese / Vietnamese / dictation / autocorrect
6
+ // suggestion bar) is active. Without a handler, the user sees nothing
7
+ // while typing because keydown/input fire only when the composed
8
+ // phrase is finalised.
9
+ //
10
+ // What this module does:
11
+ // 1. On compositionstart, mark composing=true and capture textarea
12
+ // selection bounds. Show the floating compositionView at the
13
+ // cursor with the in-progress text.
14
+ // 2. On compositionupdate, mirror the in-progress text into
15
+ // compositionView (\u200E LTR markers, RTL CSS direction so long
16
+ // compositions track their tail).
17
+ // 3. On compositionend, set a setTimeout(0) finaliser. The
18
+ // compositionend.data property is unreliable on Chromium (Korean
19
+ // ending consonant moves to next char on vowel input, etc.); the
20
+ // textarea value at next tick has the truth.
21
+ // 4. While composing, callers' keydown handler must early-return
22
+ // (handled via isComposing()). xterm.js takes the same approach.
23
+ //
24
+ // Positioning: the compositionView is absolutely positioned inside
25
+ // `termWrap`. The IME UI (browser-rendered popup with candidate words)
26
+ // uses the textarea as its anchor; we move the textarea to the cursor
27
+ // for the same reason.
28
+
29
+ import { keyboard } from "@cplieger/web-terminal-engine";
30
+ const { bracketTextForPaste, prepareTextForTerminal } = keyboard;
31
+
32
+ // Single-character invisible placeholder used by app.ts to keep the
33
+ // iOS soft keyboard's held-Backspace auto-repeat firing. Composition
34
+ // finalisation must restore the placeholder (and not empty the
35
+ // textarea) for the same reason.
36
+ const INPUT_PLACEHOLDER = "\u00A0";
37
+
38
+ let textarea: HTMLTextAreaElement;
39
+ let compositionView: HTMLElement;
40
+ let getCursorPx: () => { left: number; top: number; cellH: number };
41
+ let send: (bytes: string) => void;
42
+
43
+ let composing = false;
44
+ let sendingComposition = false;
45
+ let compositionStart = 0;
46
+ let compositionEnd = 0;
47
+ let compositionSuffix = "";
48
+
49
+ export function init(opts: {
50
+ textarea: HTMLTextAreaElement;
51
+ compositionView: HTMLElement;
52
+ getCursorPx: () => { left: number; top: number; cellH: number };
53
+ send: (bytes: string) => void;
54
+ }): void {
55
+ textarea = opts.textarea;
56
+ compositionView = opts.compositionView;
57
+ getCursorPx = opts.getCursorPx;
58
+ send = opts.send;
59
+
60
+ textarea.addEventListener("compositionstart", onStart);
61
+ textarea.addEventListener("compositionupdate", onUpdate);
62
+ textarea.addEventListener("compositionend", onEnd);
63
+ textarea.addEventListener("paste", onPaste);
64
+ }
65
+
66
+ /** True while an IME composition is in progress. Caller's keydown
67
+ * listener must early-return on true to avoid sending raw key bytes
68
+ * during composition. */
69
+ export function isComposing(): boolean {
70
+ return composing || sendingComposition;
71
+ }
72
+
73
+ function onStart(): void {
74
+ composing = true;
75
+ const start = textarea.selectionStart;
76
+ const end = textarea.selectionEnd;
77
+ compositionStart = Math.min(start, end);
78
+ compositionEnd = Math.max(start, end);
79
+ compositionSuffix = textarea.value.substring(compositionEnd);
80
+ compositionView.textContent = "";
81
+ compositionView.classList.add("active");
82
+ positionCompositionView();
83
+ }
84
+
85
+ function onUpdate(ev: CompositionEvent): void {
86
+ // \u200E (LTR mark) wrappers + CSS direction:rtl on the view make
87
+ // long compositions show their trailing edge instead of being
88
+ // clipped at the start. Pattern from xterm.js.
89
+ compositionView.textContent = `\u200E${ev.data}\u200E`;
90
+ positionCompositionView();
91
+ // Schedule a microtask to refresh the end position after the
92
+ // textarea selection settles.
93
+ setTimeout(() => {
94
+ const end = textarea.selectionEnd;
95
+ compositionEnd = Math.max(compositionStart, end);
96
+ }, 0);
97
+ }
98
+
99
+ function onEnd(): void {
100
+ compositionView.classList.remove("active");
101
+ composing = false;
102
+ // The compositionend event fires before the textarea reflects the
103
+ // final value on most browsers (Chromium especially). Defer one tick
104
+ // and read the textarea then; xterm.js's pattern.
105
+ sendingComposition = true;
106
+ const startSnapshot = compositionStart;
107
+ const suffixSnapshot = compositionSuffix;
108
+ setTimeout(() => {
109
+ if (!sendingComposition) {
110
+ return;
111
+ }
112
+ sendingComposition = false;
113
+ const value = textarea.value;
114
+ const valueEnd =
115
+ suffixSnapshot.length > 0 && value.endsWith(suffixSnapshot)
116
+ ? value.length - suffixSnapshot.length
117
+ : value.length;
118
+ const composed = value.substring(startSnapshot, Math.max(startSnapshot, valueEnd));
119
+ if (composed.length > 0) {
120
+ send(composed);
121
+ }
122
+ resetTextareaToPlaceholder();
123
+ }, 0);
124
+ }
125
+
126
+ function onPaste(ev: ClipboardEvent): void {
127
+ // Native `paste` event handler. Required for iOS where Ctrl+Shift+V
128
+ // is unavailable; users invoke paste from the iOS callout menu and
129
+ // it fires this event on the focused textarea.
130
+ if (!ev.clipboardData) {
131
+ return;
132
+ }
133
+ const raw = ev.clipboardData.getData("text/plain");
134
+ if (raw === "") {
135
+ return;
136
+ }
137
+ ev.preventDefault();
138
+ ev.stopPropagation();
139
+ send(bracketTextForPaste(prepareTextForTerminal(raw)));
140
+ // Restore the placeholder so the subsequent `input` event (some
141
+ // browsers fire it after paste) and held-Backspace auto-repeat keep
142
+ // working.
143
+ resetTextareaToPlaceholder();
144
+ }
145
+
146
+ function resetTextareaToPlaceholder(): void {
147
+ textarea.value = INPUT_PLACEHOLDER;
148
+ try {
149
+ textarea.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
150
+ } catch {
151
+ // Some older WebKit builds throw on setSelectionRange against a
152
+ // visually-hidden textarea; ignore.
153
+ }
154
+ }
155
+
156
+ /** Position the composition view (and the helper textarea, which
157
+ * anchors the IME popup) at the current terminal cursor pixel
158
+ * position. iOS keyboards open without scrolling the layout when
159
+ * the focused textarea is on screen at a sane location. */
160
+ export function positionCompositionView(): void {
161
+ const { left, top, cellH } = getCursorPx();
162
+ compositionView.style.left = `${left}px`;
163
+ compositionView.style.top = `${top}px`;
164
+ compositionView.style.height = `${cellH}px`;
165
+ compositionView.style.lineHeight = `${cellH}px`;
166
+ // Textarea piggy-backs on the cursor position so iOS scroll-into-
167
+ // view targets the cursor area instead of jumping to top-left.
168
+ textarea.style.left = `${left}px`;
169
+ textarea.style.top = `${top}px`;
170
+ textarea.style.height = `${cellH}px`;
171
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // @cplieger/web-terminal-ui — the reference touch-first browser UI built on
2
+ // the @cplieger/web-terminal-engine engine. The public surface is a single mount()
3
+ // call against the shipped scaffold (scaffold/index.html) + CSS (css/).
4
+ //
5
+ // Consumers who want a different UI should depend on @cplieger/web-terminal-engine
6
+ // directly and wire the engine's render/scroll/connection/keyboard modules
7
+ // to their own DOM.
8
+
9
+ export { mount } from "./mount.js";
10
+ export type { MountOptions, TerminalUI } from "./mount.js";