@cplieger/web-terminal-ui 5.0.3 → 5.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.
@@ -0,0 +1,216 @@
1
+ // tabs/notify.ts — OSC 9 Form B notifications (`OSC 9 ; <message> ST`) as
2
+ // BROWSER notifications. The spec names the display mode ("post a
3
+ // notification"), which is why this one signal is not tab-only chrome: a
4
+ // finished turn or a permission prompt has to be able to reach a user who is
5
+ // looking at another app.
6
+ //
7
+ // A notification is an EVENT, not a state (that is the structural difference
8
+ // from OSC 9;4 progress, which is a state and drives the status dot): the engine
9
+ // latches nothing for it and never replays it, so it is delivered once, on the
10
+ // sweep that first observes it.
11
+ //
12
+ // DOM-free and global-free by construction: every capability comes in through
13
+ // NotifierEnv, so the decisions here are unit-testable without a browser
14
+ // permission model, and the one place that touches globals is
15
+ // browserNotifierEnv() below. The notification text NEVER enters the DOM — see
16
+ // the note on deliver().
17
+
18
+ /** The slice of `window.Notification` used here. Constructed and dropped: the
19
+ * browser owns the notification's life, and we keep no handle (nothing in this
20
+ * UI closes or replaces one programmatically). */
21
+ export type NotificationCtorLike = new (
22
+ title: string,
23
+ options?: { body?: string; tag?: string },
24
+ ) => unknown;
25
+
26
+ /** The capabilities a notifier needs, all injected. `permission` and `request`
27
+ * are read as plain strings rather than the DOM's NotificationPermission union:
28
+ * they come from a browser that may report something newer, and a value we do
29
+ * not recognise must degrade rather than be asserted. */
30
+ export interface NotifierEnv {
31
+ /** The Notification constructor, or undefined where the API does not exist
32
+ * (iOS Safari outside an installed PWA, a non-secure context, happy-dom). */
33
+ ctor: NotificationCtorLike | undefined;
34
+ /** Current permission: "default" | "granted" | "denied" (or anything else). */
35
+ permission: () => string;
36
+ /** Ask for permission. Must be called from a user gesture. */
37
+ request: () => void;
38
+ /** Is the page currently visible to the user? */
39
+ pageVisible: () => boolean;
40
+ }
41
+
42
+ /** One status event's notification payload (the fields SessionStatus carries for
43
+ * it). Both are optional: an older server, or any event that carries no
44
+ * notification, simply omits them. */
45
+ interface NotificationEvent {
46
+ readonly id: string;
47
+ readonly notification?: string | undefined;
48
+ readonly notificationSeq?: number | undefined;
49
+ }
50
+
51
+ /** shouldNotify is the suppression rule, alone and testable.
52
+ *
53
+ * Skip a notification ONLY when the user is already looking at the terminal that
54
+ * produced it: its tab is the active one AND the page is visible. Both halves
55
+ * are required, and the second is the one that matters most — when the page is
56
+ * HIDDEN (another browser tab, a locked phone, a minimised window) the
57
+ * notification fires even for the active session, because that is exactly the
58
+ * situation where the user cannot see the terminal at all. A rule that keyed
59
+ * only on "is this the active tab" would silently swallow the notification of
60
+ * the very session the user left running. */
61
+ export function shouldNotify(sessionIsActive: boolean, pageVisible: boolean): boolean {
62
+ return !(sessionIsActive && pageVisible);
63
+ }
64
+
65
+ /** Bound on the per-session dedupe map, same reasoning as the model's other
66
+ * bounded stores: each key is a live session server-side, so a real one is
67
+ * nowhere near this, and a hostile or buggy stream cannot grow it without end. */
68
+ const MAX_TRACKED_SESSIONS = 200;
69
+
70
+ export interface Notifier {
71
+ /** Deliver one status event's notification, if it carries a new one. Returns
72
+ * true when a browser notification was actually posted (false covers every
73
+ * degraded path: no notification in the event, a repeat, suppression, no API,
74
+ * no permission, a throwing constructor). */
75
+ deliver(ev: NotificationEvent, view: { sessionIsActive: boolean; label: string }): boolean;
76
+ /** Note that this page has a session capable of notifying, which is what makes
77
+ * a permission prompt worth raising at all. */
78
+ arm(): void;
79
+ /** Note a user gesture: the ONLY moment a permission prompt may be raised. */
80
+ gesture(): void;
81
+ /** Drop a closed session's dedupe state. */
82
+ forget(id: string): void;
83
+ }
84
+
85
+ export function createNotifier(env: NotifierEnv): Notifier {
86
+ // Highest notification sequence delivered per session. The engine never
87
+ // replays a notification, so this is a belt-and-braces guard rather than the
88
+ // mechanism that makes re-delivery safe: it costs one map and it means a
89
+ // duplicated frame (a doubled subscriber, a consumer re-emitting a snapshot)
90
+ // cannot notify twice for one event.
91
+ const lastSeq = new Map<string, number>();
92
+ let armed = false;
93
+ let requested = false;
94
+
95
+ function evict(): void {
96
+ while (lastSeq.size > MAX_TRACKED_SESSIONS) {
97
+ const oldest = lastSeq.keys().next().value;
98
+ if (oldest === undefined) {
99
+ return;
100
+ }
101
+ lastSeq.delete(oldest);
102
+ }
103
+ }
104
+
105
+ return {
106
+ deliver(ev, view): boolean {
107
+ const text = ev.notification ?? "";
108
+ if (text === "") {
109
+ return false;
110
+ }
111
+ const seq = ev.notificationSeq ?? 0;
112
+ if (seq !== 0) {
113
+ if (seq <= (lastSeq.get(ev.id) ?? 0)) {
114
+ return false; // already delivered this event
115
+ }
116
+ lastSeq.set(ev.id, seq);
117
+ evict();
118
+ }
119
+ if (!shouldNotify(view.sessionIsActive, env.pageVisible())) {
120
+ return false;
121
+ }
122
+ const Ctor = env.ctor;
123
+ if (!Ctor || env.permission() !== "granted") {
124
+ // Degrade silently to tab-only: the session's dot and the switcher's
125
+ // aggregate cue already carry that something happened. No throw, no
126
+ // console noise — an unpermitted notification is a normal state of the
127
+ // world, not an error.
128
+ return false;
129
+ }
130
+ try {
131
+ // The message is UNTRUSTED program output. It is passed as DATA to the
132
+ // Notification API (a string option, rendered by the browser's own
133
+ // notification surface) and is never written into the DOM — not as
134
+ // innerHTML, not as a template string that becomes markup, not into a
135
+ // label. The engine already strips control/bidi runes and clamps the
136
+ // length; that is not widened here. If this ever needs an in-page
137
+ // surface, it must use textContent.
138
+ new Ctor(view.label, { body: text, tag: ev.id });
139
+ return true;
140
+ } catch {
141
+ // Safari throws on `new Notification` outside an installed PWA even when
142
+ // the constructor exists. Tab-only, silently.
143
+ return false;
144
+ }
145
+ },
146
+ arm(): void {
147
+ armed = true;
148
+ },
149
+ gesture(): void {
150
+ // Permission is requested from a USER GESTURE, never at load: Safari
151
+ // rejects (and Chrome penalises) a prompt with no gesture behind it, and a
152
+ // prompt on page load is the pattern browsers added those rules for.
153
+ //
154
+ // Gated on `armed` as well, so only a page that actually has a
155
+ // notification-capable session ever prompts: a plain shell emits no OSC 9,
156
+ // and asking its user for notification permission is a prompt they can
157
+ // only answer wrongly. The cost of that gate is that the FIRST
158
+ // notification of a session cannot be shown (the prompt has not been
159
+ // answered yet) — unavoidable in any case, since permission cannot be
160
+ // obtained without a gesture, and a first signal rarely coincides with one.
161
+ if (!armed || requested) {
162
+ return;
163
+ }
164
+ const Ctor = env.ctor;
165
+ if (!Ctor || env.permission() !== "default") {
166
+ return; // absent API, or already granted/denied: nothing to ask
167
+ }
168
+ requested = true; // once per page, whatever the answer
169
+ try {
170
+ env.request();
171
+ } catch {
172
+ /* a browser that refuses to be asked is a tab-only browser */
173
+ }
174
+ },
175
+ forget(id): void {
176
+ lastSeq.delete(id);
177
+ },
178
+ };
179
+ }
180
+
181
+ /** browserNotifierEnv binds a notifier to the real browser APIs. The single
182
+ * place in this feature that touches globals, and it reads them through
183
+ * `unknown` on purpose: `Notification` is absent (not merely denied) on iOS
184
+ * Safari and in test DOMs, so nothing here may assume the declared global
185
+ * exists. Both `requestPermission` shapes are tolerated — modern browsers
186
+ * return a promise, older Safari takes a callback and returns undefined — since
187
+ * the result is not awaited: the answer is read from `permission()` at the next
188
+ * notification. */
189
+ export function browserNotifierEnv(): NotifierEnv {
190
+ const api = (): { permission?: unknown; requestPermission?: unknown } | undefined => {
191
+ const value: unknown = (globalThis as { Notification?: unknown }).Notification;
192
+ return typeof value === "function"
193
+ ? (value as unknown as { permission?: unknown; requestPermission?: unknown })
194
+ : undefined;
195
+ };
196
+ return {
197
+ get ctor(): NotificationCtorLike | undefined {
198
+ const value: unknown = (globalThis as { Notification?: unknown }).Notification;
199
+ return typeof value === "function" ? (value as NotificationCtorLike) : undefined;
200
+ },
201
+ permission: (): string => {
202
+ const value = api()?.permission;
203
+ return typeof value === "string" ? value : "denied";
204
+ },
205
+ request: (): void => {
206
+ const fn = api()?.requestPermission;
207
+ if (typeof fn === "function") {
208
+ (fn as () => unknown).call(api());
209
+ }
210
+ },
211
+ // document.visibilityState is the only reliable "can the user see this page"
212
+ // signal; document.hasFocus() is false for a visible-but-unfocused window,
213
+ // where the terminal IS on screen and a notification would be redundant.
214
+ pageVisible: (): boolean => document.visibilityState !== "hidden",
215
+ };
216
+ }
@@ -6,6 +6,11 @@
6
6
  // (it closes over the feature state). The mobile chrome templates live in
7
7
  // switcher.ts; the session model in model.ts.
8
8
 
9
+ // The status vocabulary itself (which statuses reveal a dot, and how each one is
10
+ // worded for a human) lives in the DOM-free model, so the painters here and the
11
+ // accessible names index.ts builds read one definition.
12
+ import { statusPhrase, statusRevealsDot } from "./model.js";
13
+
9
14
  // The +/x/keyboard glyphs are inline SVG (not font glyphs) so they center
10
15
  // exactly in their flex-centered buttons and stay symmetric regardless of the UI
11
16
  // font's metrics. Each is defined ONCE here and shared by every chip site and
@@ -21,15 +26,22 @@ const KB_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" wi
21
26
  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>`;
22
27
 
23
28
  // chipContent is the ONE builder for a tab chip's content — a status dot, a
24
- // label, and a close (x) — shared by all three chip sites: the desktop strip
25
- // (.wt-tab), the mobile active row (.wt-switcher-current), and each expanded
26
- // mobile list row (.wt-switcher-row). Each site passes its OWN class set (never
27
- // renamed) so every existing selector — and thus all CSS and all tests — still
28
- // matches. The one structural difference is WHERE the close sits: the desktop
29
+ // label, a determinate progress bar, and a close (x) — shared by all three chip
30
+ // sites: the desktop strip (.wt-tab), the mobile active row
31
+ // (.wt-switcher-current), and each expanded mobile list row (.wt-switcher-row).
32
+ // Each site passes its OWN class set (never renamed) so every existing selector
33
+ // — and thus all CSS and all tests — still matches. The one structural
34
+ // difference is WHERE the close sits: the desktop
29
35
  // chip nests it flat inside .wt-tab, while the two mobile chips place it as a
30
36
  // sibling of the select/swipe button (a button can't nest in a button). So the
31
37
  // builder returns two fragments — the dot+label pair and the close — that each
32
38
  // site drops into its own structure.
39
+ //
40
+ // The progress bar rides in the dotLabel fragment rather than taking a per-site
41
+ // class: it is positioned against the chip, not laid out in its flex row, so one
42
+ // class (.wt-progress-bar) styles it everywhere and every chip site gets it for
43
+ // free. It starts `hidden` — a session with no percentage must render NO bar
44
+ // (paintProgress).
33
45
  export function chipContent(v: { dot: string; label: string; close: string; closeAttr?: string }): {
34
46
  dotLabel: string;
35
47
  close: string;
@@ -37,7 +49,8 @@ export function chipContent(v: { dot: string; label: string; close: string; clos
37
49
  return {
38
50
  dotLabel:
39
51
  `<span class="${v.dot} wt-status-dot" aria-hidden="true"></span>` +
40
- `<span class="${v.label}"></span>`,
52
+ `<span class="${v.label}"></span>` +
53
+ `<span class="wt-progress-bar" aria-hidden="true" hidden></span>`,
41
54
  close: `<button type="button" class="${v.close}" aria-label="Close terminal"${v.closeAttr ?? ""}>${CLOSE_SVG}</button>`,
42
55
  };
43
56
  }
@@ -80,15 +93,44 @@ export const TAB_HTML = `
80
93
  ${TAB_CHIP.close}
81
94
  </div>`;
82
95
 
83
- /** paintStatusDot applies a status dot's two orthogonal bits: data-status drives
84
- * its appearance (idle / working / done / input / exited via CSS), and the
85
- * .wt-reports class controls its visibility — the dot is hidden by default and
86
- * shown only once the session has reported activity (OSC 9;4 progress or a
87
- * classified OSC 9 notification), so a plain shell's tabs stay clean and
88
- * label-only while an agent's light up. */
96
+ /** paintStatusDot applies a status dot's three orthogonal bits: data-status
97
+ * drives its appearance (idle / working / warning / failed / done / input /
98
+ * exited / crashed via CSS), the .wt-reports class controls its visibility, and
99
+ * `title` gives it a hover tooltip naming the state — the dots are decoration
100
+ * (aria-hidden), and a colour vocabulary that grew to eight states needs a way
101
+ * to be read rather than memorised. The tooltip wording is statusPhrase, the
102
+ * same source the tab's accessible name uses, so hover text and announced text
103
+ * cannot drift.
104
+ *
105
+ * Visibility: the dot is hidden by default and shown once the session has
106
+ * reported activity (OSC 9;4 progress or a classified OSC 9 notification), so a
107
+ * plain shell's tabs stay clean and label-only while an agent's light up. The
108
+ * reveal is FLOORED by the status itself (statusRevealsDot) for the states that
109
+ * are self-evidently news — a plain shell that crashes never reported activity
110
+ * in its life, and hiding its red dot would hide the only signal it ever
111
+ * produced. */
89
112
  export function paintStatusDot(el: HTMLElement, status: string, reports: boolean): void {
90
- el.dataset["status"] = status || "idle";
91
- el.classList.toggle("wt-reports", reports);
113
+ const value = status || "idle";
114
+ el.dataset["status"] = value;
115
+ el.classList.toggle("wt-reports", reports || statusRevealsDot(value));
116
+ el.title = statusPhrase(value);
117
+ }
118
+
119
+ /** paintProgress renders one chip's determinate progress bar from a percentage.
120
+ *
121
+ * An absent percentage (PROGRESS_ABSENT / any negative) renders NO bar: the
122
+ * element is `hidden` and its width is cleared, rather than left as a
123
+ * zero-width or empty bar that would read as "0%, stalled". The width is the
124
+ * only inline style — the percentage is data, and everything about how the bar
125
+ * looks stays in .wt-progress-bar. */
126
+ export function paintProgress(el: HTMLElement, progress: number): void {
127
+ if (progress < 0) {
128
+ el.hidden = true;
129
+ el.style.removeProperty("width");
130
+ return;
131
+ }
132
+ el.hidden = false;
133
+ el.style.width = `${String(progress)}%`;
92
134
  }
93
135
 
94
136
  /** pick returns a required descendant element or throws (static chrome only). */
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@
13
13
 
14
14
  export { createTerminal } from "./kernel/kernel.js";
15
15
  export { STARTUP_FAILURE_COPY } from "./kernel/startup-copy.js";
16
+ export { LOADING_OVERLAY_CLASSES, PUBLIC_THEME_TOKENS } from "./kernel/style-contract.js";
17
+ export type { PublicThemeToken } from "./kernel/style-contract.js";
16
18
  export type {
17
19
  CreateTerminalOptions,
18
20
  TerminalStartupFailure,
@@ -0,0 +1,96 @@
1
+ // The names that cross this package's boundary as STRINGS, published as data.
2
+ //
3
+ // Two surfaces of this library are addressed by name rather than by type, so no
4
+ // compiler on either side of the boundary checks them:
5
+ //
6
+ // - `theme` is `Readonly<Record<string, string>>`. The kernel copies every key
7
+ // onto the terminal root verbatim, so a consumer that names a token this
8
+ // library renamed or retired gets a live declaration nothing reads: no
9
+ // error, no warning, just the library's own defaults where the consumer's
10
+ // brand was meant to be.
11
+ // - The loading overlay is the CONSUMER's element (it must paint before this
12
+ // module loads), styled by `css/page.css` purely because the markup opts in
13
+ // by class. Those classes are usually hardcoded in a static HTML file no
14
+ // compiler reads at all.
15
+ //
16
+ // Both were previously prose — a doc comment on the option, a sentence in the
17
+ // README — which a consumer could only agree with by remembering. Published as
18
+ // data they can be ASSERTED against instead: web-terminal-kiro's app.test.ts
19
+ // checks its theme keys and its overlay markup against these exports the same
20
+ // way it already checks its bootstrap watchdog's wording against
21
+ // STARTUP_FAILURE_COPY. Same reason, same shape: this file imports nothing and
22
+ // touches no DOM, so a test or a Node build script can read it without loading
23
+ // the kernel.
24
+ //
25
+ // These lists are HAND-WRITTEN on purpose. They are the public contract — a
26
+ // decision about what this library promises to support — not a fact derived
27
+ // from the stylesheets. What IS derived from the stylesheets is the
28
+ // verification that the promise holds: `src/css-contract.test.ts` generates the
29
+ // declared/referenced token inventory from the CSS itself and fails if any
30
+ // token named here is not both DECLARED and READ by a rule. A token that is
31
+ // declared but never read is the silent-no-op case above, reached from the
32
+ // library's side instead of the consumer's, and no hand-maintained list can
33
+ // catch it.
34
+
35
+ /** CSS custom properties a consumer may set through `CreateTerminalOptions.theme`.
36
+ *
37
+ * Every entry is guaranteed to be both declared and read by the shipped
38
+ * stylesheets (see the inventory test), so setting any of them changes what the
39
+ * terminal renders. Anything NOT listed here is unsupported: it may be an
40
+ * internal token that is renamed without a release note, or it may not exist.
41
+ *
42
+ * Two families, and one rule about the second:
43
+ *
44
+ * - `--accent` and the `--tab-*` family recolor the chrome. `--accent` is the
45
+ * widest lever — the "+" glyphs, and the active-tab border via a `color-mix`
46
+ * derivation — so a single override themes several surfaces.
47
+ * - The `--status-*` family is the activity-dot palette. The three ANIMATED
48
+ * states (`working` / `warning` / `failed`) differ from one another only in
49
+ * HUE, so replacements must keep their LIGHTNESS spread: equal-lightness
50
+ * substitutes collapse into one indistinguishable state in greyscale and
51
+ * under deuteranopia. `crashed` deliberately has no token of its own — it
52
+ * reuses `--status-failed`, solid and static, separating from `failed` by
53
+ * motion rather than color.
54
+ *
55
+ * Values are arbitrary CSS, including `var()` references. A value that reaches
56
+ * through `var()` to an INTERNAL token couples the consumer to a name this
57
+ * list does not cover — assert on those separately if you write one. */
58
+ export const PUBLIC_THEME_TOKENS = [
59
+ "--accent",
60
+ "--tab-bg",
61
+ "--tab-hover-bg",
62
+ "--tab-active-bg",
63
+ "--tab-active-fg",
64
+ "--tab-active-border",
65
+ "--status-working",
66
+ "--status-done",
67
+ "--status-input",
68
+ "--status-warning",
69
+ "--status-failed",
70
+ ] as const;
71
+
72
+ /** A CSS custom property `CreateTerminalOptions.theme` supports.
73
+ *
74
+ * `theme` itself stays an open `Record<string, string>` — narrowing it would
75
+ * break every consumer that builds its theme object dynamically — so this type
76
+ * is offered for a consumer that wants its OWN theme constant typed closed,
77
+ * which is where the mistake is actually made. */
78
+ export type PublicThemeToken = (typeof PUBLIC_THEME_TOKENS)[number];
79
+
80
+ /** The class names `css/page.css` styles the pre-JS loading overlay by.
81
+ *
82
+ * Selected by CLASS, never by the consumer's element id: the same
83
+ * no-element-id-contract rule the kernel follows, and an unnamespaced name
84
+ * would collide in a host document. Markup contract: one element carrying
85
+ * `overlay` plus `role="status"`, with one `bar` child. Recolor it by setting
86
+ * the `--wt-loading-*` custom properties on that element — it is the
87
+ * consumer's element, so no JS option is involved.
88
+ *
89
+ * The kernel writes its progressive startup status line into a region it
90
+ * CREATES inside the overlay (`kernel/loading-status.ts`), so those class
91
+ * names are the kernel's own and are not part of the markup a consumer
92
+ * writes. */
93
+ export const LOADING_OVERLAY_CLASSES = {
94
+ overlay: "wt-loading",
95
+ bar: "wt-loading-bar",
96
+ } as const;
@@ -426,12 +426,19 @@ export interface CreateTerminalOptions {
426
426
  // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- an observing handler returns nothing; only a literal `true` claims the recovery surface, so forcing `return undefined` on every observer is worse than the union
427
427
  onFatalError?: (failure: TerminalStartupFailure) => boolean | void;
428
428
  /** Theme overrides: CSS custom properties set on the terminal root so a
429
- * consumer recolors the UI (accent, tab hover/active) without shipping CSS.
430
- * Keys must be CSS custom-property names (start with "--"); values are any
431
- * CSS value. The library ships the defaults (css/00-tokens.css) — the
432
- * "template"; these are the consumer's "settings" and override them for this
433
- * instance. Known tokens: --accent, --tab-bg, --tab-hover-bg,
434
- * --tab-active-bg, --tab-active-fg, --tab-active-border. */
429
+ * consumer recolors the UI (accent, tab hover/active, the activity-dot
430
+ * palette) without shipping CSS. Keys must be CSS custom-property names
431
+ * (start with "--"); values are any CSS value. The library ships the defaults
432
+ * (css/00-tokens.css) — the "template"; these are the consumer's "settings"
433
+ * and override them for this instance.
434
+ *
435
+ * The supported keys are published as DATA, not prose: `PUBLIC_THEME_TOKENS`
436
+ * (exported at the package root and at "@cplieger/web-terminal-ui/style-contract").
437
+ * This type stays an open Record so a consumer may build its theme
438
+ * dynamically, which means nothing here rejects a key the library renamed or
439
+ * retired — the override would simply apply to nothing. Assert your keys
440
+ * against that list rather than trusting a comment; the library's own suite
441
+ * guarantees every token on it is both declared and read by a rule. */
435
442
  theme?: Readonly<Record<string, string>>;
436
443
  }
437
444