@cplieger/web-terminal-ui 7.3.1 → 8.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.
Files changed (48) hide show
  1. package/README.md +114 -18
  2. package/css/30-tabs.css +52 -13
  3. package/css/31-switcher.css +15 -0
  4. package/css/32-split.css +176 -0
  5. package/css/MANIFEST +1 -0
  6. package/css/MANIFEST.tabbed +1 -0
  7. package/package.json +13 -12
  8. package/src/composition.ts +165 -237
  9. package/src/features/activity-monitor.ts +20 -55
  10. package/src/features/animations.ts +12 -30
  11. package/src/features/clipboard.ts +36 -64
  12. package/src/features/connection-banner.ts +10 -19
  13. package/src/features/context-menu.ts +70 -145
  14. package/src/features/dom.ts +44 -54
  15. package/src/features/menu-position.ts +24 -28
  16. package/src/features/mobile-toolbar.ts +32 -62
  17. package/src/features/predictive-echo.ts +15 -49
  18. package/src/features/scroll-to-bottom.ts +12 -24
  19. package/src/features/tabs/index.ts +1398 -1806
  20. package/src/features/tabs/ink-centre.ts +45 -54
  21. package/src/features/tabs/model.ts +301 -383
  22. package/src/features/tabs/strip.ts +45 -97
  23. package/src/features/tabs/switcher.ts +70 -48
  24. package/src/index.ts +13 -13
  25. package/src/kernel/a11y.ts +106 -27
  26. package/src/kernel/attention.ts +157 -0
  27. package/src/kernel/conn-state.ts +28 -22
  28. package/src/kernel/fatal.ts +76 -0
  29. package/src/kernel/feature-host.ts +136 -0
  30. package/src/kernel/gesture.ts +9 -13
  31. package/src/kernel/kernel.ts +57 -2139
  32. package/src/kernel/layout-policy.ts +44 -0
  33. package/src/kernel/loading-status.ts +30 -88
  34. package/src/kernel/navigation.ts +11 -17
  35. package/src/kernel/notify.ts +158 -0
  36. package/src/kernel/pane.ts +1415 -0
  37. package/src/kernel/realm.ts +11 -0
  38. package/src/kernel/regions.ts +8 -11
  39. package/src/kernel/scrollback.ts +88 -168
  40. package/src/kernel/selection.ts +25 -0
  41. package/src/kernel/shell.ts +1434 -0
  42. package/src/kernel/split-handle.ts +280 -0
  43. package/src/kernel/status-share.ts +100 -0
  44. package/src/kernel/types.ts +457 -441
  45. package/src/predict.ts +144 -170
  46. package/src/viewport.ts +124 -197
  47. package/src/features/tabs/attention.ts +0 -250
  48. package/src/features/tabs/notify.ts +0 -265
@@ -1,18 +1,11 @@
1
- // tabs/strip.ts — the desktop tab strip's chrome vocabulary: the reorder-preview
2
- // constants, the shared SVG glyphs, the one chip-content builder every chip site
3
- // reuses, the strip tab template, the shared control markup factories, the
4
- // status-dot painter, and the required-descendant picker. Everything here is
5
- // closure-free markup + tiny pure helpers; element construction with event wiring
6
- // stays in index.ts (it closes over the feature state). The mobile chrome
7
- // templates and its gesture constants live in switcher.ts; the session model in
8
- // model.ts.
1
+ // Closure-free markup and pure helpers only; element construction with event
2
+ // wiring stays in index.ts, where it closes over the feature state.
9
3
 
10
4
  // The status vocabulary itself (which statuses reveal a dot, and how each one is
11
5
  // worded for a human) lives in the DOM-free model, so the painters here and the
12
6
  // accessible names index.ts builds read one definition.
13
7
  import { activityPhrase, normalizeActivity, statusPhrase, statusRevealsDot } from "./model.js";
14
8
 
15
- // --- Reorder preview -------------------------------------------------------
16
9
  // Every chip in this strip is the same width (see .wt-tab in 30-tabs.css: a
17
10
  // definite 300px with flex-grow 0, so a title changes the label and never the
18
11
  // box). That is what makes a reorder hard to read: the chip a release would
@@ -22,33 +15,18 @@ import { activityPhrase, normalizeActivity, statusPhrase, statusRevealsDot } fro
22
15
  // the same split switcher.ts keeps for the mobile swipe.
23
16
 
24
17
  /** How long (ms) the pointer must have been un-moved before a stationary `dragover` is
25
- * believed as "stopped".
26
- *
27
- * This is the number that decides how fast the slot opens, and it is small because a
28
- * `dragover` at an UNCHANGED position is positive evidence of rest — far better
29
- * evidence than the absence of events, which is all the fallback below has. The drag
30
- * loop keeps delivering events while the pointer is held still, so a genuinely stopped
31
- * pointer announces itself; this only filters the coincidence where one event of a
32
- * sweep happens to land within REORDER_MOVE_EPS_PX of the previous one (a direction
33
- * reversal, or a frame where the motion was almost entirely vertical). */
18
+ * believed as "stopped". Small, because a `dragover` at an UNCHANGED position is
19
+ * positive evidence of rest (the drag loop keeps delivering events while the pointer
20
+ * is held still); this only filters the coincidence where one event of a sweep lands
21
+ * within REORDER_MOVE_EPS_PX of the previous one. */
34
22
  export const REORDER_STILL_MS = 50;
35
23
 
36
24
  /** Fallback guard (ms): commit the pending slot this long after the last MOVEMENT, for
37
- * the case where `dragover` stops arriving altogether and no stationary event ever
38
- * confirms the stop.
39
- *
40
- * **Its floor is set by the platform, not by taste, and this is the timer that floor
41
- * applies to.** HTML5 drag-and-drop only guarantees a `dragover` every 350ms — the drag
42
- * loop runs on that cadence, not per mouse movement — so a guard at or below it can
43
- * expire BETWEEN two events of a fast sweep, and the strip then commits every slot the
44
- * pointer crosses. Measured in production: at 120ms a quick pass over five tabs moved
45
- * all five.
46
- *
47
- * A pure quiet-window design has to make this ONE number carry both jobs, which is why
48
- * it could not be shortened — the same timer that decides responsiveness was the one
49
- * that had to out-wait the cadence, so 500ms was safe and felt slow. Splitting rest
50
- * DETECTION (the stationary event above) from the no-events NET (here) lets each be
51
- * sized for its own job. This one is rarely the deciding signal at all. */
25
+ * the case where `dragover` stops arriving altogether. Its floor is the platform's:
26
+ * HTML5 drag-and-drop only guarantees a `dragover` every 350ms, so a guard at or
27
+ * below it can expire BETWEEN two events of a fast sweep and commit every slot the
28
+ * pointer crosses (measured: at 120ms a quick pass over five tabs moved all five).
29
+ * Rest DETECTION is the stationary event above, so this net can stay slow. */
52
30
  export const REORDER_REST_MS = 450;
53
31
 
54
32
  /** Movement (px) between two dragover events below which the pointer counts as still.
@@ -57,19 +35,12 @@ export const REORDER_REST_MS = 450;
57
35
  export const REORDER_MOVE_EPS_PX = 3;
58
36
 
59
37
  /** The one transition both preview stages use (the lean, and the slide that
60
- * commits it).
61
- *
62
- * --dur-standard and --ease-standard written out as literals. JS-driven motion
63
- * in this package does not read tokens back out of the cascade; the switcher's
64
- * release reel spells its own transition the same way. Keep the two in step: a
65
- * token edit has to be mirrored here by hand.
66
- *
67
- * The property is `translate`, NOT `transform`, and that is load-bearing rather
68
- * than stylistic. Declarations from a running CSS animation out-rank every normal
69
- * author declaration, inline style included, so a chip in the middle of
70
- * `wt-slot-in` or `wt-tab-in` (both animate `transform: scale`) would ignore an
71
- * inline `transform` outright and refuse to move. `translate` is a separate
72
- * property that composes with `transform` instead of competing for it. */
38
+ * commits it): --dur-standard and --ease-standard as literals, since JS-driven
39
+ * motion here reads no token back out of the cascade (mirror a token edit by
40
+ * hand). The property is `translate`, NOT `transform`: declarations from a
41
+ * running CSS animation out-rank inline style, so a chip mid `wt-slot-in` or
42
+ * `wt-tab-in` (both animate `transform: scale`) would ignore an inline
43
+ * `transform`; `translate` composes with it instead of competing for it. */
73
44
  export const REORDER_SHIFT_TRANS = "translate 0.2s cubic-bezier(0.2, 0, 0, 1)";
74
45
 
75
46
  /** When (ms after the slide starts) the inline transform and transition come off
@@ -103,31 +74,17 @@ const KB_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" wi
103
74
  // background-tab notification dot rides on it (see switchButtonHTML). Same
104
75
  // viewBox + stroke=currentcolor treatment as the others.
105
76
  const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="13" height="13" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3"/></svg>`;
106
-
107
- // chipContent is the ONE builder for a tab chip's content — a status dot, a
108
- // label, a determinate progress bar, and a close (x) — shared by all three chip
109
- // sites: the desktop strip (.wt-tab), the mobile active row
110
- // (.wt-switcher-current), and each expanded mobile list row (.wt-switcher-row).
111
- // Each site passes its OWN class set (never renamed) so every existing selector
112
- // — and thus all CSS and all tests — still matches. The one structural
113
- // difference is WHERE the close sits: the desktop
114
- // chip nests it flat inside .wt-tab, while the two mobile chips place it as a
115
- // sibling of the select/swipe button (a button can't nest in a button). So the
116
- // builder returns two fragments — the dot+label pair and the close — that each
117
- // site drops into its own structure.
118
- //
119
- // The progress bar rides in the dotLabel fragment rather than taking a per-site
120
- // class: it is positioned against the chip, not laid out in its flex row, so one
121
- // class (.wt-progress-bar) styles it everywhere and every chip site gets it for
122
- // free. It starts `hidden` — a session with no percentage must render NO bar
123
- // (paintProgress).
124
- //
125
- // The secondary activity mark (.wt-activity-mark) rides here for the same reason
126
- // and takes no per-site class either. It carries NO `hidden` attribute, unlike
127
- // the bar: `hidden` is display:none, and an element out of layout cannot animate
128
- // into it. Absence is the absence of `data-activity`, which the CSS answers by
129
- // cancelling the mark's own footprint (see 30-tabs.css), so a chip with no
130
- // secondary activity measures exactly as it did before this element existed.
77
+ // Two rectangles side by side, the same glyph whether the split is open or
78
+ // closed: the button's state is its aria-expanded, not its icon.
79
+ const SPLIT_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="8" height="14" rx="2"/><rect x="13" y="5" width="8" height="14" rx="2"/></svg>`;
80
+
81
+ /** The ONE builder for a tab chip's content (status dot, label, progress bar,
82
+ * close), shared by the strip chip, the mobile active row and each mobile list
83
+ * row. Two fragments, because the mobile chips place the close as a SIBLING of
84
+ * their button (a button cannot nest in a button). The bar starts `hidden` (no
85
+ * percentage renders no bar); the activity mark carries no `hidden`, since an
86
+ * element out of layout cannot animate into it, and its absence is the absence
87
+ * of `data-activity`, which the CSS answers by cancelling its footprint. */
131
88
  export function chipContent(v: { dot: string; label: string; close: string; closeAttr?: string }): {
132
89
  dotLabel: string;
133
90
  close: string;
@@ -152,6 +109,12 @@ export function newButtonHTML(cls: string): string {
152
109
  export function kbButtonHTML(cls: string): string {
153
110
  return `<button type="button" class="${cls}" aria-label="Keyboard keys" aria-expanded="false" hidden>${KB_SVG}</button>`;
154
111
  }
112
+ /** The split-view toggle: a stable label with `aria-expanded` as its state, the
113
+ * keyboard button's pattern, so a screen reader hears the change on the focused
114
+ * button after a keyboard toggle. */
115
+ export function splitButtonHTML(cls: string): string {
116
+ return `<button type="button" class="${cls}" aria-label="Split view" aria-expanded="false">${SPLIT_SVG}</button>`;
117
+ }
155
118
  // The mobile switcher's dedicated open/close button. It toggles the tab list and
156
119
  // carries a latest-wins notification dot (a child span) — amber when a background
157
120
  // terminal needs input, green when a background turn finished — cleared when the
@@ -180,22 +143,12 @@ export const TAB_HTML = `
180
143
  ${TAB_CHIP.close}
181
144
  </div>`;
182
145
 
183
- /** paintStatusDot applies a status dot's three orthogonal bits: data-status
184
- * drives its appearance (idle / working / warning / failed / done / input /
185
- * exited / crashed via CSS), the .wt-reports class controls its visibility, and
186
- * `title` gives it a hover tooltip naming the state — the dots are decoration
187
- * (aria-hidden), and a colour vocabulary that grew to eight states needs a way
188
- * to be read rather than memorised. The tooltip wording is statusPhrase, the
189
- * same source the tab's accessible name uses, so hover text and announced text
190
- * cannot drift.
191
- *
192
- * Visibility: the dot is hidden by default and shown once the session has
193
- * reported activity (OSC 9;4 progress or a classified OSC 9 notification), so a
194
- * plain shell's tabs stay clean and label-only while an agent's light up. The
195
- * reveal is FLOORED by the status itself (statusRevealsDot) for the states that
196
- * are self-evidently news — a plain shell that crashes never reported activity
197
- * in its life, and hiding its red dot would hide the only signal it ever
198
- * produced. */
146
+ /** A status dot's three orthogonal bits: data-status drives its appearance, the
147
+ * .wt-reports class its visibility, and `title` a tooltip worded by statusPhrase,
148
+ * the source the tab's accessible name uses, so hover and announced text cannot
149
+ * drift. Hidden until the session reports activity, so a plain shell's tabs stay
150
+ * label-only; FLOORED by the status itself for the states that are news on their
151
+ * own, since a plain shell that crashes never reported activity in its life. */
199
152
  export function paintStatusDot(el: HTMLElement, status: string, reports: boolean): void {
200
153
  const value = status || "idle";
201
154
  el.dataset["status"] = value;
@@ -203,16 +156,11 @@ export function paintStatusDot(el: HTMLElement, status: string, reports: boolean
203
156
  el.title = statusPhrase(value);
204
157
  }
205
158
 
206
- /** paintActivityMark applies the secondary mark's two bits: data-activity drives
207
- * its appearance (working / waiting / input via CSS) and `title` gives it a
208
- * hover tooltip, worded by activityPhrase so hover text and announced text
209
- * cannot drift. No state REMOVES the attribute, which is what collapses the
210
- * mark's footprint to zero.
211
- *
212
- * The reveal is the mark's OWN state, deliberately not the status dot's
213
- * reportsActivity gate: a background run is a fact about the session that is
214
- * independent of whether its program ever spoke OSC 9, which is the whole point
215
- * of a second mark. */
159
+ /** The secondary mark's two bits: data-activity drives its appearance and `title`
160
+ * a tooltip worded by activityPhrase. No state REMOVES the attribute, which is
161
+ * what collapses the mark's footprint to zero. The reveal is the mark's OWN
162
+ * state, not the status dot's reportsActivity gate: a background run is a fact
163
+ * independent of whether the program ever spoke OSC 9. */
216
164
  export function paintActivityMark(el: HTMLElement, state: string, count: number): void {
217
165
  const phrase = activityPhrase(state, count);
218
166
  if (phrase === "") {
@@ -1,11 +1,8 @@
1
- // tabs/switcher.ts — the mobile bottom-switcher's chrome vocabulary: its
2
- // templates, the swipe/flick gesture constants, and the closure-free row
3
- // enter/leave animations. The gesture STATE MACHINE (axis lock, live preview,
4
- // the release reel) stays in index.ts: it mutates drag state shared with the
5
- // switch/expand paths, so extracting it would spread one interaction across two
6
- // files. The desktop chrome vocabulary lives in strip.ts; the session model in
7
- // model.ts.
1
+ // The swipe state machine (axis lock, live preview, the release reel) stays in
2
+ // index.ts: it mutates drag state shared with the switch and expand paths, so
3
+ // extracting it would spread one interaction across two files.
8
4
 
5
+ import { windowOf } from "../../kernel/realm.js";
9
6
  import { chipContent } from "./strip.js";
10
7
 
11
8
  // Swipe recognition on the mobile switcher bar: a mostly-horizontal drag past
@@ -31,24 +28,11 @@ export const VELOCITY_STALE_MS = 32;
31
28
  export const PREVIEW_DRAG_RATIO = 0.1;
32
29
  export const PREVIEW_PEEK_MAX = 10;
33
30
 
34
- // Mobile bottom bar. One element, two parts stacked in a column: the always-
35
- // visible bar row (active tab as a tap/swipe surface + keyboard + "+") on top,
36
- // and a list of the OTHER tabs BELOW it. On swipe-up / tap the whole bar slides
37
- // up and the list fills in beneath it (down to the safe area); swipe-down /
38
- // tap collapses it back to the bottom. Selecting a listed tab swaps it into the
39
- // active row. This replaces the old separate modal overview sheet ("one element"
40
- // per the user): the bar itself lifts rather than opening a distinct surface.
41
- // The bar is the FIRST child and the list the SECOND: the switcher is bottom-
42
- // anchored, so a column with the list last grows the container upward, lifting
43
- // the bar and revealing the list below it (DOM order = visual order top-to-bottom).
44
- // - .wt-switcher-current-wrap: the active-tab row — a select/swipe surface
45
- // (.wt-switcher-current with dot + label) plus a close (x) overlaid at the
46
- // right, mirroring the listed rows. No "n / m" counter: the list below is a
47
- // rotating circular queue, so an absolute position number is meaningless.
48
- // - The keyboard button (.wt-switcher-kb, opens the key grid above the bar,
49
- // only wired + shown when a keyboardToggle feature is provided) and the
50
- // accent "+" (.wt-switcher-new, spawns a terminal) are appended to the bar
51
- // row from the shared factories in index.ts (see makeKbButton / makeNewButton).
31
+ // Mobile bottom bar: one element, the always-visible bar row (active tab as a
32
+ // tap/swipe surface, keyboard, "+") FIRST and the list of the other tabs SECOND.
33
+ // The switcher is bottom-anchored, so a column with the list last grows upward
34
+ // on swipe-up and lifts the bar to reveal the list beneath it. No "n / m"
35
+ // counter: the list is a rotating circular queue, so a position is meaningless.
52
36
  const CURRENT_CHIP = chipContent({
53
37
  dot: "wt-switcher-dot",
54
38
  label: "wt-switcher-label",
@@ -85,45 +69,83 @@ export const SWITCHER_ROW_HTML = `
85
69
  // the row's own max-height grows from 0 (fading in) on add, and collapses
86
70
  // to 0 (fading out, then removed) on close. The flex list's height follows
87
71
  // the row, so adding/closing a tab animates the tray height rather than
88
- // snapping. Inline-driven (cleared when done); the caller gates motion.
72
+ // snapping. Inline-driven (cleared when done); the caller gates motion, and
73
+ // its `signal` ends a motion still running when the owner goes away.
89
74
  const ROW_ANIM_MS = 220;
90
75
  const ROW_ANIM_EASE = "cubic-bezier(0.2, 0, 0, 1)";
91
76
 
92
- export function animateRowIn(row: HTMLElement): void {
77
+ /** The frame that writes the "to" state and the timer that ends the motion,
78
+ * both cancelled by `signal`. The timer also cancels the frame: a background
79
+ * tab suspends frames while timers run, and a frame released after `finish()`
80
+ * would write the "to" state onto a row nobody owns any more. A motion that
81
+ * finished releases its abort listener so a long-lived signal collects nothing. */
82
+ function rowMotion(
83
+ row: HTMLElement,
84
+ signal: AbortSignal,
85
+ toState: () => void,
86
+ finish: () => void,
87
+ ): void {
88
+ const win = windowOf(row.ownerDocument);
89
+ const frame = win.requestAnimationFrame(toState);
90
+ const timer = win.setTimeout(() => {
91
+ win.cancelAnimationFrame(frame);
92
+ signal.removeEventListener("abort", cancel);
93
+ finish();
94
+ }, ROW_ANIM_MS + 60);
95
+ const cancel = (): void => {
96
+ win.cancelAnimationFrame(frame);
97
+ win.clearTimeout(timer);
98
+ };
99
+ signal.addEventListener("abort", cancel, { once: true });
100
+ }
101
+
102
+ export function animateRowIn(row: HTMLElement, signal: AbortSignal): void {
93
103
  const h = row.getBoundingClientRect().height;
94
- if (h <= 0) {
104
+ if (h <= 0 || signal.aborted) {
95
105
  return;
96
106
  }
97
107
  row.style.overflow = "hidden";
98
108
  row.style.transition = "none";
99
109
  row.style.maxHeight = "0";
100
110
  row.style.opacity = "0";
101
- requestAnimationFrame(() => {
102
- row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
103
- row.style.maxHeight = `${String(Math.ceil(h))}px`;
104
- row.style.opacity = "1";
105
- });
106
- window.setTimeout(() => {
107
- row.style.transition = "";
108
- row.style.maxHeight = "";
109
- row.style.opacity = "";
110
- row.style.overflow = "";
111
- }, ROW_ANIM_MS + 60);
111
+ rowMotion(
112
+ row,
113
+ signal,
114
+ () => {
115
+ row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
116
+ row.style.maxHeight = `${String(Math.ceil(h))}px`;
117
+ row.style.opacity = "1";
118
+ },
119
+ () => {
120
+ row.style.transition = "";
121
+ row.style.maxHeight = "";
122
+ row.style.opacity = "";
123
+ row.style.overflow = "";
124
+ },
125
+ );
112
126
  }
113
127
 
114
- export function animateRowOut(row: HTMLElement): void {
128
+ export function animateRowOut(row: HTMLElement, signal: AbortSignal): void {
129
+ if (signal.aborted) {
130
+ row.remove();
131
+ return;
132
+ }
115
133
  const h = row.getBoundingClientRect().height;
116
134
  row.style.overflow = "hidden";
117
135
  row.style.pointerEvents = "none";
118
136
  row.style.transition = "none";
119
137
  row.style.maxHeight = `${String(Math.ceil(h))}px`;
120
138
  row.style.opacity = "1";
121
- requestAnimationFrame(() => {
122
- row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
123
- row.style.maxHeight = "0";
124
- row.style.opacity = "0";
125
- });
126
- window.setTimeout(() => {
127
- row.remove();
128
- }, ROW_ANIM_MS + 60);
139
+ rowMotion(
140
+ row,
141
+ signal,
142
+ () => {
143
+ row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
144
+ row.style.maxHeight = "0";
145
+ row.style.opacity = "0";
146
+ },
147
+ () => {
148
+ row.remove();
149
+ },
150
+ );
129
151
  }
package/src/index.ts CHANGED
@@ -1,16 +1,3 @@
1
- // @cplieger/web-terminal-ui — the reference touch-first browser UI built on the
2
- // @cplieger/web-terminal-engine engine.
3
- //
4
- // v3 entry: createTerminal(root, { features }) builds a small kernel (display
5
- // output, hidden textarea, IME, engine wiring, input funnel, connection-state,
6
- // layout regions) plus opt-in feature modules. Feature bundles live at
7
- // "@cplieger/web-terminal-ui/presets" (presetSingle / presetTouch /
8
- // presetTabbed); a consumer that hand-picks features imports them individually.
9
- //
10
- // A consumer who wants a different UI depends on @cplieger/web-terminal-engine
11
- // directly and wires the engine's render/scroll/connection/keyboard modules to
12
- // their own DOM.
13
-
14
1
  export { createTerminal } from "./kernel/kernel.js";
15
2
  export { localScrollbackStorage } from "./kernel/scrollback-storage.js";
16
3
  export type { LocalScrollbackStorageOptions } from "./kernel/scrollback-storage.js";
@@ -26,6 +13,19 @@ export type {
26
13
  PersistedScrollback,
27
14
  ScrollbackPersistence,
28
15
  SessionOwnerRegistration,
16
+ PaneLayoutOwnerRegistration,
17
+ PaneSide,
18
+ PaneHandle,
19
+ ShellContext,
20
+ AttentionOptions,
21
+ AttentionState,
22
+ AttentionReporter,
23
+ NotificationEvent,
24
+ NotificationView,
25
+ Notifier,
26
+ SplitState,
27
+ SplitController,
28
+ ModeReaders,
29
29
  TerminalContext,
30
30
  TerminalEvents,
31
31
  RegionName,
@@ -1,9 +1,10 @@
1
- // Kernel-owned accessibility primitives (design sections 12, 22.4): one polite
2
- // and one assertive live region so features never spawn competing aria-live
3
- // regions, and one tablist/tabpanel controller wired on the kernel's output
4
- // surface so `tabs` gets the ARIA seam without crossing into kernel-owned DOM.
1
+ // Kernel-owned accessibility primitives: one polite and one assertive live
2
+ // region so features never spawn competing aria-live regions, and one
3
+ // tablist/tabpanel controller wired on the kernel's output surface so `tabs`
4
+ // gets the ARIA seam without crossing into kernel-owned DOM.
5
5
 
6
- import type { TablistController, TabHandle } from "./types.js";
6
+ import type { PaneSide, TablistController, TabHandle } from "./types.js";
7
+ import { windowOf } from "./realm.js";
7
8
 
8
9
  /** Visually-hidden style applied inline so it holds without external CSS
9
10
  * (tests run without the stylesheet). Standard sr-only clip technique. */
@@ -32,11 +33,13 @@ const REANNOUNCE_DELAY_MS = 100;
32
33
 
33
34
  /** Build the single pair of live regions inside root. */
34
35
  export function createAnnouncer(root: HTMLElement): Announcer {
35
- const polite = document.createElement("div");
36
+ const doc = root.ownerDocument;
37
+ const win = windowOf(doc);
38
+ const polite = doc.createElement("div");
36
39
  polite.setAttribute("aria-live", "polite");
37
40
  polite.setAttribute("aria-atomic", "true");
38
41
  hideVisually(polite);
39
- const assertive = document.createElement("div");
42
+ const assertive = doc.createElement("div");
40
43
  assertive.setAttribute("aria-live", "assertive");
41
44
  assertive.setAttribute("aria-atomic", "true");
42
45
  hideVisually(assertive);
@@ -44,7 +47,7 @@ export function createAnnouncer(root: HTMLElement): Announcer {
44
47
 
45
48
  // Pending re-set timers, one per region, so a rapid follow-up announcement
46
49
  // replaces the pending one instead of interleaving with it.
47
- const timers = new Map<HTMLElement, ReturnType<typeof setTimeout>>();
50
+ const timers = new Map<HTMLElement, number>();
48
51
 
49
52
  function announce(message: string, politeness: "polite" | "assertive" = "polite"): void {
50
53
  const el = politeness === "assertive" ? assertive : polite;
@@ -57,11 +60,11 @@ export function createAnnouncer(root: HTMLElement): Announcer {
57
60
  el.textContent = "";
58
61
  const pending = timers.get(el);
59
62
  if (pending !== undefined) {
60
- clearTimeout(pending);
63
+ win.clearTimeout(pending);
61
64
  }
62
65
  timers.set(
63
66
  el,
64
- setTimeout(() => {
67
+ win.setTimeout(() => {
65
68
  timers.delete(el);
66
69
  el.textContent = message;
67
70
  }, REANNOUNCE_DELAY_MS),
@@ -70,7 +73,7 @@ export function createAnnouncer(root: HTMLElement): Announcer {
70
73
 
71
74
  function destroy(): void {
72
75
  for (const t of timers.values()) {
73
- clearTimeout(t);
76
+ win.clearTimeout(t);
74
77
  }
75
78
  timers.clear();
76
79
  polite.remove();
@@ -83,37 +86,111 @@ export function createAnnouncer(root: HTMLElement): Announcer {
83
86
  let panelSeq = 0;
84
87
  let tabSeq = 0;
85
88
 
86
- /** Wire the ARIA tablist/tabpanel seam on the kernel's output surface. */
87
- export function createTablist(panel: HTMLElement): TablistController {
89
+ /** One pane's tabpanel: labelled by the tab it shows, described by the shell's
90
+ * selection text. */
91
+ export interface PaneTablist {
92
+ panelId(): string;
93
+ labelBy(tabId: string): void;
94
+ /** Drop the label when `tabId` is the tab labelling the panel. */
95
+ unlabel(tabId: string): void;
96
+ /** The panel's `aria-describedby` text; empty removes it. */
97
+ setDescription(text: string): void;
98
+ }
99
+
100
+ /** Wire the tabpanel half of the ARIA seam on a pane's output surface. */
101
+ export function createPaneTablist(panel: HTMLElement): PaneTablist {
88
102
  panelSeq += 1;
89
103
  const panelId = panel.id || `wt-panel-${String(panelSeq)}`;
90
104
  panel.id = panelId;
91
105
  panel.setAttribute("role", "tabpanel");
106
+ let description: HTMLElement | null = null;
107
+
108
+ return {
109
+ panelId: () => panelId,
110
+ labelBy(tabId) {
111
+ panel.setAttribute("aria-labelledby", tabId);
112
+ },
113
+ unlabel(tabId) {
114
+ if (panel.getAttribute("aria-labelledby") === tabId) {
115
+ panel.removeAttribute("aria-labelledby");
116
+ }
117
+ },
118
+ setDescription(text) {
119
+ if (text === "") {
120
+ description?.remove();
121
+ description = null;
122
+ panel.removeAttribute("aria-describedby");
123
+ return;
124
+ }
125
+ if (description === null) {
126
+ description = panel.ownerDocument.createElement("span");
127
+ description.id = `${panelId}-description`;
128
+ hideVisually(description);
129
+ panel.parentElement?.appendChild(description);
130
+ panel.setAttribute("aria-describedby", description.id);
131
+ }
132
+ description.textContent = text;
133
+ },
134
+ };
135
+ }
92
136
 
137
+ /** Wire the tab-row half of the ARIA seam. `panelFor(side)` answers the pane
138
+ * showing a tab, and `panelFor(null)` the pane a click on an unshown tab would
139
+ * fill; either may be null before that pane is built. */
140
+ export function createTablist(
141
+ panelFor: (side: PaneSide | null) => PaneTablist | null,
142
+ ): TablistController {
93
143
  function registerTab(tab: HTMLElement): TabHandle {
94
144
  tabSeq += 1;
95
145
  const tabId = tab.id || `wt-tab-${String(tabSeq)}`;
96
146
  tab.id = tabId;
97
147
  tab.setAttribute("role", "tab");
98
- tab.setAttribute("aria-controls", panelId);
99
148
  tab.setAttribute("aria-selected", "false");
100
149
  // Roving tabindex (WAI-ARIA APG Tabs pattern): only the selected tab is in
101
150
  // the Tab sequence; the others are focusable programmatically (arrow keys).
102
151
  tab.tabIndex = -1;
152
+ let side: PaneSide | null = null;
153
+ let selected = false;
154
+ let labelled: PaneTablist | null = null;
155
+ function paintPanel(): void {
156
+ const panel = panelFor(side);
157
+ if (panel === null) {
158
+ tab.removeAttribute("aria-controls");
159
+ } else {
160
+ tab.setAttribute("aria-controls", panel.panelId());
161
+ }
162
+ // A panel is labelled by the selected tab that controls it, and by nothing else.
163
+ const labelling = selected ? panel : null;
164
+ if (labelling !== labelled) {
165
+ labelled?.unlabel(tabId);
166
+ labelling?.labelBy(tabId);
167
+ labelled = labelling;
168
+ }
169
+ }
170
+ paintPanel();
103
171
 
104
172
  return {
105
- setSelected(selected: boolean): void {
106
- tab.setAttribute("aria-selected", selected ? "true" : "false");
107
- tab.tabIndex = selected ? 0 : -1;
108
- if (selected) {
109
- // The panel is labelled by whichever tab is currently selected.
110
- panel.setAttribute("aria-labelledby", tabId);
173
+ setSelected(on: boolean): void {
174
+ selected = on;
175
+ tab.setAttribute("aria-selected", on ? "true" : "false");
176
+ tab.tabIndex = on ? 0 : -1;
177
+ paintPanel();
178
+ },
179
+ setPanel(next: PaneSide | null): void {
180
+ side = next;
181
+ paintPanel();
182
+ },
183
+ setExpanded(state: boolean | null): void {
184
+ if (state === null) {
185
+ tab.removeAttribute("aria-expanded");
186
+ } else {
187
+ tab.setAttribute("aria-expanded", state ? "true" : "false");
111
188
  }
112
189
  },
113
190
  setLabel(text: string): void {
114
191
  tab.setAttribute("aria-label", text);
115
192
  },
116
- setEditing(editing: boolean, selected: boolean): void {
193
+ setEditing(editing: boolean, on: boolean): void {
117
194
  if (editing) {
118
195
  // Drop the tab semantics while a textbox lives inside the chip, and
119
196
  // take the chip out of the roving sequence so Tab moves past it to the
@@ -125,24 +202,26 @@ export function createTablist(panel: HTMLElement): TablistController {
125
202
  tab.tabIndex = -1;
126
203
  return;
127
204
  }
205
+ selected = on;
128
206
  tab.setAttribute("role", "tab");
129
- tab.setAttribute("aria-selected", selected ? "true" : "false");
130
- tab.tabIndex = selected ? 0 : -1;
207
+ tab.setAttribute("aria-selected", on ? "true" : "false");
208
+ tab.tabIndex = on ? 0 : -1;
209
+ paintPanel();
131
210
  },
132
211
  remove(): void {
133
212
  tab.removeAttribute("role");
134
213
  tab.removeAttribute("aria-controls");
135
214
  tab.removeAttribute("aria-selected");
215
+ tab.removeAttribute("aria-expanded");
136
216
  tab.removeAttribute("tabindex");
137
- if (panel.getAttribute("aria-labelledby") === tabId) {
138
- panel.removeAttribute("aria-labelledby");
139
- }
217
+ labelled?.unlabel(tabId);
218
+ labelled = null;
140
219
  },
141
220
  };
142
221
  }
143
222
 
144
223
  return {
145
- panelId: () => panelId,
224
+ panelId: () => panelFor(null)?.panelId() ?? "",
146
225
  registerTab,
147
226
  };
148
227
  }