@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.
- package/README.md +55 -15
- package/css/00-tokens.css +53 -1
- package/css/30-tabs.css +178 -76
- package/css/31-switcher.css +29 -25
- package/css/page.css +8 -4
- package/package.json +3 -2
- package/src/features/activity-monitor.ts +4 -2
- package/src/features/tabs/index.ts +149 -42
- package/src/features/tabs/model.ts +166 -7
- package/src/features/tabs/notify.ts +216 -0
- package/src/features/tabs/strip.ts +56 -14
- package/src/index.ts +2 -0
- package/src/kernel/style-contract.ts +96 -0
- package/src/kernel/types.ts +13 -6
|
@@ -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
|
|
25
|
-
// (.wt-tab), the mobile active row
|
|
26
|
-
// mobile list row (.wt-switcher-row).
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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
|
|
84
|
-
* its appearance (idle / working /
|
|
85
|
-
* .wt-reports class controls its visibility
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
-
|
|
91
|
-
el.
|
|
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;
|
package/src/kernel/types.ts
CHANGED
|
@@ -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
|
|
430
|
-
* Keys must be CSS custom-property names
|
|
431
|
-
* CSS value. The library ships the defaults
|
|
432
|
-
* "template"; these are the consumer's "settings"
|
|
433
|
-
*
|
|
434
|
-
*
|
|
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
|
|