@cplieger/web-terminal-ui 1.0.1 → 2.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.
package/README.md CHANGED
@@ -1,14 +1,20 @@
1
1
  # @cplieger/web-terminal-ui
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@cplieger/web-terminal-ui)](https://www.npmjs.com/package/@cplieger/web-terminal-ui)
4
+ [![JSR](https://jsr.io/badges/@cplieger/web-terminal-ui)](https://jsr.io/@cplieger/web-terminal-ui)
5
+ [![Test coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/web-terminal-ui/badges/coverage.json)](https://github.com/cplieger/web-terminal-ui/actions/workflows/coverage.yml)
6
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13431/badge)](https://www.bestpractices.dev/projects/13431)
7
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cplieger/web-terminal-ui/badge)](https://scorecard.dev/viewer/?uri=github.com/cplieger/web-terminal-ui)
8
+
3
9
  The reference touch-first browser UI for
4
10
  [`@cplieger/web-terminal-engine`](https://github.com/cplieger/web-terminal-engine) — the part
5
11
  that turns the engine's render/scroll/connection/keyboard modules into a
6
12
  usable terminal on a phone as well as a desktop.
7
13
 
8
- One `mount()` call wires the engine to a small HTML scaffold and owns
9
- everything above the raw terminal:
14
+ One `mount(root)` call builds the entire terminal UI inside a single container
15
+ element you provide and owns everything above the raw terminal:
10
16
 
11
- - a **display-only** `#term-output` (native text selection survives redraws)
17
+ - a **display-only** terminal output (native text selection survives redraws)
12
18
  - a hidden `<textarea>` that owns the keyboard, the local typing buffer, and IME
13
19
  - a **mobile key toolbar** (Tab / Esc / arrows / Enter / sticky-Ctrl) and a
14
20
  scroll-to-bottom control
@@ -20,7 +26,7 @@ everything above the raw terminal:
20
26
  - a connection-status banner and a copy toast
21
27
 
22
28
  It is published as TypeScript source (no build step) to npm and JSR, alongside
23
- the CSS bundle and an HTML scaffold. Consumers who want a different UI should
29
+ the CSS bundle and a reference HTML page. Consumers who want a different UI should
24
30
  depend on the engine directly and skip this package.
25
31
 
26
32
  ## Install
@@ -35,41 +41,73 @@ pins the engine version explicitly.
35
41
 
36
42
  ## Usage
37
43
 
38
- Serve the scaffold (`scaffold/index.html` — copy and adapt it) and the bundled
39
- CSS (`css/` concatenated per `css/MANIFEST` into the `style.css` the scaffold
40
- links), then call `mount()` from your entry module:
41
-
42
- ```ts
43
- import { mount } from "@cplieger/web-terminal-ui";
44
-
45
- mount();
46
- // or, for a server that exposes the WebSocket elsewhere / a custom font:
47
- mount({ wsPath: "/api/shell/ws", fontReady: '14px "MyMono"' });
44
+ Serve the bundled CSS (`css/` concatenated per `css/MANIFEST` into the
45
+ `style.css` your page links) plus a minimal HTML page that has one empty
46
+ container element for the terminal, then call `mount(root)` from your entry
47
+ module:
48
+
49
+ ```html
50
+ <div id="terminal"></div>
51
+ <div id="loading">Loading…</div>
52
+ <script type="importmap">
53
+ {
54
+ "imports": {
55
+ "@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js",
56
+ "@cplieger/web-terminal-ui": "/vendor/cplieger-web-terminal-ui/index.js"
57
+ }
58
+ }
59
+ </script>
60
+ <script type="module">
61
+ import { mount } from "@cplieger/web-terminal-ui";
62
+ mount(document.getElementById("terminal"), {
63
+ loading: document.getElementById("loading"),
64
+ });
65
+ // or, for a server that exposes the WebSocket elsewhere / a custom font:
66
+ // mount(root, { wsPath: "/api/shell/ws", fontReady: '14px "MyMono"' });
67
+ </script>
48
68
  ```
49
69
 
50
- `mount()` expects the scaffold's element ids to exist in the DOM (`term`,
51
- `term-output`, `term-input`, `composition-view`, `pred-cursor`, `loading`,
52
- `conn-banner`, `key-toolbar`, the `kb-*` buttons, `scroll-bottom`, `ctx-menu`)
53
- and throws a clear error if a required one is missing. Call it exactly once —
54
- the UI is single-instance per page.
70
+ `mount(root, opts?)` builds the entire terminal subtree (the display output,
71
+ the hidden keyboard textarea, the mobile key toolbar, the status banner, and
72
+ the context menu) inside `root` itself — there is no element-id contract for
73
+ the host page to reproduce. Call it exactly once; the module state is
74
+ single-instance per page. `scaffold/index.html` is a complete reference page to
75
+ copy and adapt.
55
76
 
56
77
  ### Options
57
78
 
58
- | Option | Default | Purpose |
59
- | ----------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
60
- | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
61
- | `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
79
+ | Option | Default | Purpose |
80
+ | ----------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
81
+ | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
82
+ | `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
83
+ | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); mount fades it out and removes it once the first frame renders. |
62
84
 
63
85
  `mount()` returns a small handle: `{ focus() }` re-focuses the terminal input
64
86
  (and opens the soft keyboard on touch).
65
87
 
66
88
  ## What ships
67
89
 
68
- | Path | Purpose |
69
- | ------------------------ | ----------------------------------------------------------------- |
70
- | `src/*.ts` | The UI modules (`mount`, IME, predictive echo, viewport, status). |
71
- | `css/*.css` + `MANIFEST` | The default theme + layout; concatenate in MANIFEST order. |
72
- | `scaffold/index.html` | A reference HTML scaffold with the required element ids. |
90
+ | Path | Purpose |
91
+ | ------------------------ | --------------------------------------------------------------------- |
92
+ | `src/*.ts` | The UI modules (`mount`, IME, predictive echo, viewport, status). |
93
+ | `css/*.css` + `MANIFEST` | The default theme + layout; concatenate in MANIFEST order. |
94
+ | `scaffold/index.html` | A reference HTML page: `<head>` + one empty root element + importmap. |
95
+
96
+ ## Related projects
97
+
98
+ The web-terminal family:
99
+
100
+ - [`web-terminal-engine`](https://github.com/cplieger/web-terminal-engine) — the
101
+ Go session engine + TypeScript browser renderer this UI is built on (peer
102
+ dependency).
103
+ - [`web-terminal-server`](https://github.com/cplieger/web-terminal-server) — a
104
+ ready-to-run container that serves this UI over HTTP + WebSocket for any
105
+ command.
106
+
107
+ Consumers that ship this UI:
108
+
109
+ - [`vibekit`](https://github.com/cplieger/vibekit)
110
+ - [`vibecli`](https://github.com/cplieger/vibecli)
73
111
 
74
112
  ## License
75
113
 
package/css/00-tokens.css CHANGED
@@ -30,4 +30,5 @@
30
30
  --ease-exit: cubic-bezier(0.4, 0, 1, 1);
31
31
  --blur: blur(8px);
32
32
  --safe-top: env(safe-area-inset-top, 0px);
33
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
33
34
  }
package/css/02-app.css CHANGED
@@ -48,11 +48,10 @@
48
48
  client-side cap or a server-reported resume gap). A quiet, centered,
49
49
  non-selectable divider so the user knows scrollback doesn't go further up. */
50
50
  .term-trim-marker {
51
- color: color-mix(in oklch, var(--text) 45%, transparent);
51
+ color: color-mix(in oklch, var(--text) 60%, transparent);
52
52
  font-size: 0.6875rem;
53
53
  text-align: center;
54
54
  padding: 2px 0;
55
- opacity: 0.7;
56
55
  user-select: none;
57
56
  font-family: var(--font-ui);
58
57
  }
@@ -141,10 +140,11 @@
141
140
  display: block;
142
141
  }
143
142
 
144
- /* Connection-state banner: floats top-center with a subtle backdrop
145
- blur. Hidden until status.ts sets `.visible`. Distinct from the
146
- `.pill` family because it wants to span across the full top center
147
- visually rather than dock to a corner. */
143
+ /* Connection-state banner: bottom-center on touch (lifted above the
144
+ virtual keyboard via --kb-inset), top-center on desktop. Subtle
145
+ backdrop blur. Hidden until status.ts sets `.visible`. Distinct from
146
+ the `.pill` family because it wants to span the full width centrally
147
+ rather than dock to a corner. */
148
148
  .conn-banner {
149
149
  position: fixed;
150
150
  bottom: var(--sp-3);
@@ -208,6 +208,18 @@
208
208
  }
209
209
  }
210
210
 
211
+ /* Desktop (pointer: fine): move the banner to top-center so it no longer
212
+ shares the bottom-center anchor with the scroll-to-bottom FAB
213
+ (.kb-scroll-bottom). Sharing it let the banner (pointer-events:auto,
214
+ z-index:20) render over the FAB and intercept its click. Touch keeps
215
+ the bottom placement and the --kb-inset keyboard lift above. */
216
+ @media (pointer: fine) {
217
+ .conn-banner {
218
+ top: var(--sp-3);
219
+ bottom: auto;
220
+ }
221
+ }
222
+
211
223
  /* Clickable URLs */
212
224
  .term-link {
213
225
  color: inherit;
@@ -430,7 +442,7 @@
430
442
  z-index: 10;
431
443
  transition:
432
444
  opacity var(--dur-standard) var(--ease-standard),
433
- background-color 150ms var(--ease-standard),
445
+ background-color var(--dur-micro) var(--ease-standard),
434
446
  display var(--dur-standard) allow-discrete;
435
447
  }
436
448
 
@@ -696,9 +708,9 @@
696
708
  opacity: 0;
697
709
  scale: 0.95;
698
710
  transition:
699
- opacity 0.15s ease,
700
- scale 0.15s ease,
701
- display 0.15s allow-discrete;
711
+ opacity var(--dur-exit) var(--ease-standard),
712
+ scale var(--dur-exit) var(--ease-standard),
713
+ display var(--dur-exit) allow-discrete;
702
714
  }
703
715
 
704
716
  .ctx-menu.visible {
@@ -728,3 +740,15 @@
728
740
  .ctx-menu button:active {
729
741
  background: var(--text-active);
730
742
  }
743
+
744
+ /* Touch (long-press) menu: bump the rows to the 44px --touch-target
745
+ standard the rest of the touch UI uses (the key-toolbar grid is
746
+ repeat(4, var(--touch-target))). Desktop right-click keeps the
747
+ compact 24px menu. padding-block re-centres the label in the taller box. */
748
+ @media (pointer: coarse) {
749
+ .ctx-menu button {
750
+ min-height: var(--touch-target);
751
+ padding-block: var(--sp-2);
752
+ touch-action: manipulation;
753
+ }
754
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "1.0.1",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
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
6
  "license": "GPL-3.0-or-later",
@@ -27,13 +27,13 @@
27
27
  "@eslint/js": "10.0.1",
28
28
  "@types/node": "25.9.4",
29
29
  "@vitest/coverage-v8": "4.1.9",
30
- "eslint": "10.5.0",
30
+ "eslint": "10.6.0",
31
31
  "fast-check": "4.8.0",
32
32
  "happy-dom": "20.10.6",
33
33
  "html-validate": "11.5.3",
34
- "knip": "6.20.0",
35
- "prettier": "3.8.4",
36
- "stylelint": "17.13.0",
34
+ "knip": "6.22.0",
35
+ "prettier": "3.9.0",
36
+ "stylelint": "17.14.0",
37
37
  "stylelint-config-standard": "40.0.0",
38
38
  "typescript": "6.0.3",
39
39
  "typescript-eslint": "8.62.0",
@@ -16,37 +16,24 @@
16
16
  </style>
17
17
  </head>
18
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>
19
+ <!-- mount() builds the entire terminal UI inside this single root element.
20
+ The loading overlay is a sibling (not a child of the root) so mount never
21
+ clears it; mount fades it out once the first frame renders. -->
22
+ <div id="terminal"></div>
25
23
  <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. -->
24
+ <!-- Deployment-specific: the importmap resolves the bare package specifiers to
25
+ the compiled JS your build emits. Adjust the paths to your build layout. -->
43
26
  <script type="importmap">
44
27
  {
45
28
  "imports": {
46
- "@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js"
29
+ "@cplieger/web-terminal-engine": "/vendor/cplieger-web-terminal-engine/index.js",
30
+ "@cplieger/web-terminal-ui": "/vendor/cplieger-web-terminal-ui/index.js"
47
31
  }
48
32
  }
49
33
  </script>
50
- <script type="module" src="/app.js"></script>
34
+ <script type="module">
35
+ import { mount } from "@cplieger/web-terminal-ui";
36
+ mount(document.getElementById("terminal"), { loading: document.getElementById("loading") });
37
+ </script>
51
38
  </body>
52
39
  </html>
@@ -27,14 +27,9 @@
27
27
  // for the same reason.
28
28
 
29
29
  import { keyboard } from "@cplieger/web-terminal-engine";
30
+ import { resetToPlaceholder } from "./input-placeholder.js";
30
31
  const { bracketTextForPaste, prepareTextForTerminal } = keyboard;
31
32
 
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
33
  let textarea: HTMLTextAreaElement;
39
34
  let compositionView: HTMLElement;
40
35
  let getCursorPx: () => { left: number; top: number; cellH: number };
@@ -43,7 +38,6 @@ let send: (bytes: string) => void;
43
38
  let composing = false;
44
39
  let sendingComposition = false;
45
40
  let compositionStart = 0;
46
- let compositionEnd = 0;
47
41
  let compositionSuffix = "";
48
42
 
49
43
  export function init(opts: {
@@ -75,7 +69,7 @@ function onStart(): void {
75
69
  const start = textarea.selectionStart;
76
70
  const end = textarea.selectionEnd;
77
71
  compositionStart = Math.min(start, end);
78
- compositionEnd = Math.max(start, end);
72
+ const compositionEnd = Math.max(start, end);
79
73
  compositionSuffix = textarea.value.substring(compositionEnd);
80
74
  compositionView.textContent = "";
81
75
  compositionView.classList.add("active");
@@ -88,12 +82,6 @@ function onUpdate(ev: CompositionEvent): void {
88
82
  // clipped at the start. Pattern from xterm.js.
89
83
  compositionView.textContent = `\u200E${ev.data}\u200E`;
90
84
  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
85
  }
98
86
 
99
87
  function onEnd(): void {
@@ -119,7 +107,7 @@ function onEnd(): void {
119
107
  if (composed.length > 0) {
120
108
  send(composed);
121
109
  }
122
- resetTextareaToPlaceholder();
110
+ resetToPlaceholder(textarea);
123
111
  }, 0);
124
112
  }
125
113
 
@@ -140,17 +128,7 @@ function onPaste(ev: ClipboardEvent): void {
140
128
  // Restore the placeholder so the subsequent `input` event (some
141
129
  // browsers fire it after paste) and held-Backspace auto-repeat keep
142
130
  // 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
- }
131
+ resetToPlaceholder(textarea);
154
132
  }
155
133
 
156
134
  /** Position the composition view (and the helper textarea, which
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
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/).
2
+ // the @cplieger/web-terminal-engine engine. The public surface is a single
3
+ // mount(root) call that builds the whole terminal subtree inside a
4
+ // host-provided element; scaffold/index.html + css/ are a reference page to copy.
4
5
  //
5
6
  // Consumers who want a different UI should depend on @cplieger/web-terminal-engine
6
7
  // directly and wire the engine's render/scroll/connection/keyboard modules
@@ -0,0 +1,29 @@
1
+ // Single source of truth for the hidden-textarea placeholder shared by
2
+ // mount.ts and composition.ts.
3
+ //
4
+ // A single-character placeholder is kept in the hidden textarea so iOS soft
5
+ // keyboards have something to "delete" when the user holds Backspace. iOS only
6
+ // fires repeating `input` events with inputType="deleteContentBackward" when
7
+ // the textarea has content to delete; with a perpetually empty textarea,
8
+ // holding Backspace deletes one char and stops because iOS sees nothing more to
9
+ // remove. The placeholder itself is invisible (textarea has opacity:0) and is
10
+ // stripped out of every send. NBSP is chosen specifically so screen-reader
11
+ // announcement of the input state stays empty-ish rather than "space".
12
+ //
13
+ // The constant AND the reset routine must stay in lockstep across the two
14
+ // modules (mount.ts seeds + re-pads it; composition.ts restores it on
15
+ // compositionend / paste) or the iOS held-Backspace key-repeat silently breaks
16
+ // with no compile error — which is why they live here in one place.
17
+ export const INPUT_PLACEHOLDER = "\u00A0";
18
+
19
+ // Restore the placeholder and put the cursor at the end, so the next typed
20
+ // character appends after it rather than before.
21
+ export function resetToPlaceholder(textarea: HTMLTextAreaElement): void {
22
+ textarea.value = INPUT_PLACEHOLDER;
23
+ try {
24
+ textarea.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
25
+ } catch {
26
+ // Some older WebKit builds throw on setSelectionRange against a
27
+ // visually-hidden textarea; ignore.
28
+ }
29
+ }