@cplieger/web-terminal-ui 5.0.2 → 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 CHANGED
@@ -59,15 +59,19 @@ page that has one empty container element, then call
59
59
  **Full-page host** (the terminal IS the page, as in `web-terminal-server` and
60
60
  `web-terminal-kiro`): concatenate `css/MANIFEST` into the `style.css` your page
61
61
  links. That reference bundle is `css/page.css` (the page kit: `html/body`
62
- reset + the terminal web font's `@font-face`, expecting the font files at
63
- `/vendor/fonts/`) plus the complete component set.
62
+ reset + the bundled web font's `@font-face`, expecting the font files at
63
+ `/vendor/fonts/`) plus the complete component set. The bundled font backs both
64
+ the terminal display and the chrome text, so serving those files is not
65
+ optional for a full-page host.
64
66
 
65
67
  **Embedder** (the terminal lives inside your app's layout, as a panel or pane):
66
68
  concatenate the per-preset manifest matching your composition instead:
67
69
  `css/MANIFEST.single`, `css/MANIFEST.touch`, or `css/MANIFEST.tabbed`. These
68
70
  contain ONLY root-scoped component styles: no page reset, no fonts, no
69
- document-level rules, nothing to quarantine. Pass `layout: "container"` so the
70
- terminal fills (and positions its chrome against) your container element
71
+ document-level rules, nothing to quarantine. Chrome text then falls back to your
72
+ platform's monospace unless you declare the `@font-face` yourself; ship it if you
73
+ want the terminal panel to match the full-page product. Pass `layout: "container"`
74
+ so the terminal fills (and positions its chrome against) your container element
71
75
  instead of the viewport.
72
76
 
73
77
  Feature bundles (presets) live at the `./presets` sub-path, with per-preset
@@ -134,20 +138,29 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
134
138
  label follows the latest submitted line (persisted server-side and recovered
135
139
  on reload) and the program's OSC 0/2 title is ignored. With `presumeReports`,
136
140
  the idle activity dot shows from tab creation instead of waiting for the
137
- session's first OSC 9;4 signal. Its status dots (idle / working / done /
138
- needs-input) come from the same activity monitor.
141
+ session's first OSC 9;4 signal. Its status dots come from the same activity
142
+ monitor and cover eight states: the three animated OSC 9;4 progress states
143
+ (working, warning, error), the frozen ringed needs-input dot, two static discs
144
+ (a green finished turn, a red crashed process), and two hollow ones (idle and a
145
+ dim ended session). A
146
+ reported percentage additionally renders a 2px determinate bar on the chip and
147
+ a `78% ·` prefix on the tab label; it is deliberately never written to the
148
+ browser document title, since one page title cannot represent several
149
+ sessions. An OSC 9 notification is posted as a browser
150
+ notification when the user is not already looking at that terminal (permission
151
+ is requested on a user gesture; a denial degrades silently to the tab dots).
139
152
 
140
153
  ### Options
141
154
 
142
- | Option | Default | Purpose |
143
- | -------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
144
- | `features` | _(none; bare kernel)_ | A FUNCTION returning the feature list. Omitted builds only the terminal (no chrome). Pass a preset by name (`features: presetTabbed`) or a factory of your own; it is called inside the startup-failure boundary, so a preset that throws is handled rather than escaping. |
145
- | `layout` | `"viewport"` | How the terminal claims space. `"viewport"`: the root becomes a fixed full-viewport box (the full-page product). `"container"`: the root fills your container element, which becomes the styling and positioning boundary (the embedded case). |
146
- | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
147
- | `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. |
148
- | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
149
- | `onFatalError` | _(built-in recovery)_ | Called with a `TerminalStartupFailure` after a fatal startup failure, in either phase (`feature-setup` or `kernel-init`); behavior below. |
150
- | `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-bg`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`, `--tab-active-border`, plus the activity-dot palette `--status-working`, `--status-done`, `--status-input`. The library ships neutral defaults. |
155
+ | Option | Default | Purpose |
156
+ | -------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
157
+ | `features` | _(none; bare kernel)_ | A FUNCTION returning the feature list. Omitted builds only the terminal (no chrome). Pass a preset by name (`features: presetTabbed`) or a factory of your own; it is called inside the startup-failure boundary, so a preset that throws is handled rather than escaping. |
158
+ | `layout` | `"viewport"` | How the terminal claims space. `"viewport"`: the root becomes a fixed full-viewport box (the full-page product). `"container"`: the root fills your container element, which becomes the styling and positioning boundary (the embedded case). |
159
+ | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
160
+ | `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. |
161
+ | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
162
+ | `onFatalError` | _(built-in recovery)_ | Called with a `TerminalStartupFailure` after a fatal startup failure, in either phase (`feature-setup` or `kernel-init`); behavior below. |
163
+ | `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-bg`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`, `--tab-active-border`, plus the activity-dot palette `--status-working`, `--status-done`, `--status-input`, `--status-warning`, `--status-failed`. The library ships neutral defaults. If you retheme the animated trio (`--status-working` / `--status-warning` / `--status-failed`), keep their LIGHTNESS spread: they differ only in hue, so equal-lightness replacements collapse into one another in greyscale and under deuteranopia. |
151
164
 
152
165
  `createTerminal()` returns a handle: `focus()` re-focuses the terminal input
153
166
  (and opens the soft keyboard on touch); `send(bytes)` sends bytes to the active
@@ -157,6 +170,33 @@ screen without injecting keystrokes (send a redraw keystroke yourself if you
157
170
  want one, for example Ctrl+L); and `destroy()` tears every feature down and
158
171
  releases the kernel.
159
172
 
173
+ ### Themes that provably apply
174
+
175
+ `theme` is an open `Record<string, string>` — the kernel sets every key on the
176
+ terminal root verbatim — so a key this library renamed or retired becomes a live
177
+ declaration nothing reads: no error, just the library's neutral defaults where
178
+ your brand should be. The supported keys are therefore published as data:
179
+
180
+ ```ts
181
+ import { PUBLIC_THEME_TOKENS } from "@cplieger/web-terminal-ui/style-contract";
182
+ // or from the package root; the subpath imports nothing and touches no DOM,
183
+ // so a Node script can read it too.
184
+
185
+ for (const key of Object.keys(MY_THEME)) {
186
+ expect(PUBLIC_THEME_TOKENS).toContain(key); // your test, our list
187
+ }
188
+ ```
189
+
190
+ Every token on that list is guaranteed by this package's own suite to be both
191
+ declared AND read by a shipped rule, so setting it changes what renders. The
192
+ second half is the one you cannot check from outside: a token that is declared
193
+ but that no rule reads accepts your override and applies it to nothing. Anything
194
+ NOT on the list is internal and may be renamed without a release note.
195
+
196
+ `LOADING_OVERLAY_CLASSES` is published from the same module for the same reason:
197
+ your pre-JS overlay markup opts into `css/page.css`'s styling by class name, and
198
+ that markup usually lives in a static HTML file no compiler reads.
199
+
160
200
  ### Startup failures
161
201
 
162
202
  You do not need your own startup-failure UI. Every way starting up can fail ends
package/css/00-tokens.css CHANGED
@@ -82,6 +82,28 @@
82
82
  --status-working: #52a9fe; /* agent working (waves): the same blue as the default --accent */
83
83
  --status-done: #22c55e; /* turn finished: solid green */
84
84
  --status-input: #fb923c; /* action required: solid orange + ring */
85
+ /* OSC 9;4 progress states 4 (warning) and 2 (error). Both render as the SAME
86
+ object as working -- identical glow + travelling wave, one shared rule and
87
+ one shared pair of keyframes (30-tabs.css) -- differing ONLY in hue, because
88
+ the escape code models one progress indicator with several states rather
89
+ than three unrelated dots. Neither wears a ring: the frozen hard ring is
90
+ --status-input's exclusive meaning ("blocked, waiting on you"), and neither
91
+ warning nor error asks the user to act.
92
+ LIGHTNESS SEPARATION IS LOAD-BEARING, NOT DECORATIVE. Three states that
93
+ differ only by hue collapse into one under deuteranopia and in greyscale, so
94
+ the three hues are also spread across lightness. Measured OKLab L (and WCAG
95
+ relative luminance Y, the greyscale read):
96
+ warning #facc15 L 86.1% Y 63.6%
97
+ working #52a9fe L 71.9% Y 37.3%
98
+ failed #dc2626 L 57.7% Y 16.7%
99
+ ~14 L points and a wide Y gap between each neighbour, monotonic in both, so
100
+ the three stay ordered and distinguishable with hue discarded entirely. A
101
+ retheme MUST keep that spread: two animated states at the same lightness are
102
+ indistinguishable to a colour-blind user, whatever their hues. warning is
103
+ YELLOW, deliberately not amber -- amber would collide with --status-input's
104
+ orange (L 75.8%), which sits between working and warning. */
105
+ --status-warning: #facc15; /* OSC 9;4 state 4: yellow disc, working's wave */
106
+ --status-failed: #dc2626; /* OSC 9;4 state 2: red disc, working's wave; crashed reuses it solid and static */
85
107
  --radius: 0.75rem;
86
108
  --radius-inner: 0.5rem;
87
109
  /* Small square icon buttons (e.g. the 24px tab-close x): at that size the
@@ -102,8 +124,38 @@
102
124
  --sp-1: 0.25rem;
103
125
  --sp-2: 0.5rem;
104
126
  --sp-3: 0.75rem;
105
- --font-ui: -apple-system, blinkmacsystemfont, "SF Pro Text", system-ui, sans-serif;
127
+ /* Chrome text uses the SAME bundled web font as the terminal, deliberately.
128
+ A system-font stack made the tab-title alignment unfixable: every entry of
129
+ the old stack (-apple-system, BlinkMacSystemFont, "SF Pro Text") resolves
130
+ only on Apple platforms, so everywhere else it fell through to system-ui —
131
+ which Chromium resolves through fontconfig and Firefox from the desktop UI
132
+ font setting. Two different typefaces, with two different vertical metrics,
133
+ on ONE machine. Optically centring text needs the font's ascent/descent
134
+ asymmetry as a known constant (--label-ink-shift below), and an unknown
135
+ font has no constant, so no value could ever be right on both.
136
+
137
+ MonaspiceNe NFM is already served at /vendor/fonts by every full-page host
138
+ (page.css declares the faces, the Dockerfiles ship the files), so this adds
139
+ no download. A consumer serving a component-only manifest has no face for
140
+ this family and falls back to the platform monospace — and also ships no tab
141
+ strip (MANIFEST.touch excludes 30-tabs.css), so nothing metric-dependent
142
+ rides on that fallback. */
143
+ --font-ui: "MonaspiceNe NFM", ui-monospace, monospace;
106
144
  --font-mono: "MonaspiceNe NFM", monospace;
145
+ /* How far a chip label's INK sits below its line box's centre, so flex
146
+ centring can be corrected by exactly that much (see .wt-tab-label in
147
+ 30-tabs.css). Derived from MonaspiceNe NFM's own tables and outlines:
148
+
149
+ (ascent - descent) / 2 = (0.995 - 0.250) / 2 = 0.3725em
150
+ (cap ink - descender ink)/2 = (0.7344 - 0.2188) / 2 = 0.2578em
151
+ difference = 0.1147em
152
+
153
+ A constant is safe here only because the bundled font leaves engines
154
+ nothing to disagree about: hhea, OS/2 typo and usWin all declare the same
155
+ 0.995 / 0.250 with lineGap 0, and fsSelection sets USE_TYPO_METRICS, so
156
+ Chromium, Gecko and WebKit all compute the same line box. Re-derive this
157
+ number if the bundled font ever changes; do not nudge it by eye. */
158
+ --label-ink-shift: 0.1147em;
107
159
  --dur-micro: 0.1s;
108
160
  --dur-standard: 0.2s;
109
161
  --dur-enter: 0.25s;
package/css/30-tabs.css CHANGED
@@ -75,15 +75,24 @@
75
75
  width: 300px;
76
76
  flex: 0 1 auto;
77
77
  min-width: 100px;
78
+ /* Positioning context for the chip's own overlay: the OSC 9;4 determinate
79
+ progress bar sits on its bottom edge (see .wt-progress-bar). Nothing else
80
+ inside a chip is absolutely positioned, and the strip's two root-level
81
+ overlays (.wt-tab-ghost, .wt-tab-menu) are children of .wt-root, not of a
82
+ chip, so this cannot capture them. */
83
+ position: relative;
78
84
  /* ONE height for every control in the strip: the chip, the "+" and the
79
85
  keyboard button all resolve to --touch-target, so a chip and a button can
80
86
  never disagree about how tall the row is. Before this the chip's height was
81
87
  whatever its tallest child happened to be, and the "+" hard-coded a COPY of
82
88
  that arithmetic (close button + padding + border) — two sources of truth
83
- for one row, which drifted the moment a child's box grew: a browser without
84
- text-box support kept the label's untrimmed line box (31px, past the 24px
85
- close button), so the chip rendered 49px tall next to a 42px "+" and a 44px
86
- keyboard button. --touch-target is also the tap-target floor the rest of
89
+ for one row, which drifted the moment a child's box grew: the label's own
90
+ box once reached 31px, past the 24px close button, and the chip rendered
91
+ 49px tall next to a 42px "+" and a 44px keyboard button. (That growth came
92
+ from a text-box trim's descender padding being applied on the untrimmed
93
+ code path, which no longer exists -- the label's geometry is now one
94
+ unconditional rule; see .wt-tab-label. The two-sources-of-truth lesson is
95
+ what survives.) --touch-target is also the tap-target floor the rest of
87
96
  the chrome already respects, including this chip's mobile counterpart
88
97
  (.wt-switcher-current in 31-switcher.css, same declaration).
89
98
 
@@ -174,57 +183,58 @@
174
183
  }
175
184
 
176
185
  /* The label flexes to fill the fixed-width tab and truncates with an ellipsis
177
- (min-width: 0 lets a flex item shrink below its content width). */
186
+ (min-width: 0 lets a flex item shrink below its content width), and it owns the
187
+ ink-centring geometry the two switcher labels in 31-switcher.css share verbatim.
188
+
189
+ Centre the INK, not the line box. A font's metrics box reaches far higher above
190
+ the baseline than below it (the bundled font: 0.995em over, 0.250em under),
191
+ while the ink of a mixed-case title runs 0.7344em over (cap height) to 0.2188em
192
+ under (descender). Flex centring centres the BOX, which leaves the visible text
193
+ --label-ink-shift low — 0.1147em, 1.5px at this size — reading as
194
+ baseline-aligned with the activity dot's bottom edge instead of level with its
195
+ centre. Two declarations, one job, and a third that must NOT be added:
196
+
197
+ - padding-block is the ink allowance, not spacing. `overflow: hidden` (which
198
+ the ellipsis requires) clips at the padding box, and the line box clears the
199
+ tallest ink by only 0.6px, so an unpadded label shaves the top off every
200
+ acute accent and caron. 0.125em per side buys 2.2px of clearance at 13px, and
201
+ being symmetric it does not move the centred text.
202
+ - margin-block hangs TWICE the shift below the box. Flex centring centres the
203
+ MARGIN box, so a bottom-heavy margin lifts the border box by half of it --
204
+ exactly one shift -- and unlike padding it does not enlarge the clipping box.
205
+ - line-height stays at `normal`, deliberately. Setting it looks like the
206
+ responsible thing to do and it is the trap: `normal` is the ONE value whose
207
+ half-leading is zero by definition, which is what makes the baseline sit at
208
+ exactly the engine's ascent below the box top. Any other value leaves
209
+ (line-height - font box) / 2 to be halved and rounded, and Chromium floors
210
+ it -- measured with an explicit 1.25 the 14px switcher labels came out a
211
+ FULL pixel high (line box 17.5 under an 18px font box, so half-leading
212
+ -0.25 floored to -1) while the 13px strip label was fine, which is exactly
213
+ the kind of works-where-I-looked bug this rule exists to end.
214
+
215
+ What this deliberately does NOT use is `text-box: trim-both`, which all three
216
+ previous attempts were built on. It ships in Blink and WebKit only (Firefox has
217
+ it in Nightly behind layout.css.text-box.enabled), so it can only be applied
218
+ behind an @supports gate, and a gate means two engines render two different
219
+ geometries of which only the one the author is looking at ever gets tuned.
220
+ Measured on the gated version: Chromium centred to 0.02px while Firefox sat
221
+ 1.50px low with its baseline half a pixel BELOW the dot. The keyword vocabulary
222
+ is too coarse for this text besides -- `cap alphabetic` centres the cap band and
223
+ reads 1.14px low on lowercase, `ex alphabetic` centres the x-band and reads high
224
+ on capitals, and a mixed-case tab title wants neither edge.
225
+
226
+ The residual error is engines rounding ascent and descent to whole pixels, which
227
+ makes the ideal shift 0.127em at 13px and 0.099em at 14px; the one authored
228
+ 0.1147em is the unrounded truth and lands within 0.22px at both sizes, in every
229
+ engine, with no fork. */
178
230
  :where(.wt-root) .wt-tab-label {
179
231
  flex: 1 1 auto;
180
232
  min-width: 0;
181
233
  overflow: hidden;
182
234
  white-space: nowrap;
183
235
  text-overflow: ellipsis;
184
- }
185
-
186
- /* Center the INK, not the line box: UI fonts carry far more ascent than
187
- descent (the -apple-system/Noto Sans stack at 13px: 14px over / 4px under
188
- the baseline), so the flex-centered line box leaves the visible text ~1.5px
189
- low — reading as baseline-aligned with the activity dot's bottom instead of
190
- its center. Trimming the box makes flexbox center the glyph band instead of
191
- the font's metrics box.
192
-
193
- `ex`, not `cap`, is the over edge, because a tab title is sentence case and
194
- lowercase-dominant ("workspace", "refactor the auth module"). Centering the
195
- x-height/baseline band centers the total ink of such a string, since a
196
- Latin font's ascender overshoot above x-height is about its descender depth
197
- below the baseline (measured on this stack at 13px: 3.0px over vs 3.0px
198
- under, ink center 0.02px off the chip's center). `cap alphabetic` — the
199
- right choice for an ALL-CAPS label — leaves lowercase text 1.14px low,
200
- which is the original complaint barely moved.
201
-
202
- padding-block is LOAD-BEARING, not spacing: trimming puts the box's under
203
- edge ON the baseline, and `overflow: hidden` clips at the padding edge, so
204
- with no padding every descender and every tall diacritic is cut off (
205
- measured: 3.3px of p/y/g/j tail and 2.3px of an acute accent, i.e. "pygmy
206
- jaguar Ápple" rendered as "pyqmy iaquar Apple"). 0.5em restores the ink
207
- overhang the trim removes from the box; being symmetric it does not move
208
- the centered band, and being em-relative it tracks the font size.
209
-
210
- The pair is @supports-GATED because the padding is the trim's allowance and
211
- nothing else: an untrimmed line box already contains that overhang, so
212
- adding the padding there is not "harmless slack" (this comment used to
213
- claim it was) — it grew the label's box to 31px, past the 24px close button,
214
- and a browser without text-box support rendered 49px tab chips beside a
215
- 42px "+" and a 44px keyboard button. The chip's own --touch-target floor
216
- pins the row height either way; the gate is what keeps the label from
217
- pushing PAST it. Gating the shorthand (not the longhands) is deliberate: an
218
- engine that ships text-box-trim without the shorthand does not apply the
219
- declaration we actually write, so it must take the untrimmed branch too.
220
-
221
- Shared by all three chip labels (this one + the two switcher labels in
222
- 31-switcher.css). */
223
- @supports (text-box: trim-both ex alphabetic) {
224
- :where(.wt-root) .wt-tab-label {
225
- text-box: trim-both ex alphabetic;
226
- padding-block: 0.5em;
227
- }
236
+ padding-block: 0.125em;
237
+ margin-block: 0 calc(2 * var(--label-ink-shift));
228
238
  }
229
239
 
230
240
  /* A little breathing room after the activity dot, before the label. */
@@ -569,9 +579,13 @@
569
579
  }
570
580
 
571
581
  /* ===== Activity dots (activityMonitor via tabs) =====
572
- Four-state, distinguishable without color (shape + motion, not hue alone),
573
- per section 12 / WCAG 1.4.1. The one .wt-status-dot vocabulary is shared by
574
- the desktop strip, the mobile switcher, and the overview sheet rows.
582
+ Eight-state, distinguishable without color (shape + motion, not hue alone),
583
+ per section 12 / WCAG 1.4.1: three ANIMATED progress states (working /
584
+ warning / failed — one object, three hues, spread across lightness so they
585
+ survive greyscale and deuteranopia), a frozen ringed disc (input), two static
586
+ discs (green done, red crashed), and two hollow ones (idle, dim exited).
587
+ The one .wt-status-dot vocabulary is shared by the desktop strip, the mobile
588
+ switcher, and the overview sheet rows.
575
589
 
576
590
  Revealed PER TAB: the dot is hidden until its own session reports activity
577
591
  (OSC 9;4 progress, or a classified OSC 9 notification), at which point the
@@ -595,6 +609,7 @@
595
609
  from the filled done/working/input states (WCAG 1.4.1). */
596
610
  background: transparent;
597
611
  border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
612
+
598
613
  /* Reset every transient visual owned by a status. In particular, a dot that
599
614
  leaves working must stop its pulse, and a dot that enters working must not
600
615
  carry the input ring or an exited opacity into the animated paint layer. */
@@ -603,6 +618,33 @@
603
618
  animation: none;
604
619
  }
605
620
 
621
+ /* ===== Determinate progress bar (OSC 9;4 percentage) =====
622
+ The second display mode the specs name for a progress report (iTerm2 calls it
623
+ a progress bar; ConEmu also puts the value on the taskbar, which is the
624
+ document-title prefix's job). One 2px line on the chip's bottom edge, shared
625
+ by all three chip sites (desktop strip chip, mobile active row, expanded list
626
+ row) because it ships in the one chipContent builder.
627
+
628
+ The ACCENT colour, not a per-state tint: the bar answers "how far", the dot
629
+ answers "in what state", and tinting the bar per state would say the same
630
+ thing twice while making a 2px line carry hue it cannot carry legibly.
631
+
632
+ Painted only while a percentage actually exists: the element carries the
633
+ `hidden` attribute otherwise (paintProgress), so an absent value (-1) renders
634
+ NO bar rather than a zero-width or empty one. Its width is the only inline
635
+ style — the percentage is data, not styling. */
636
+ :where(.wt-root) .wt-progress-bar {
637
+ position: absolute;
638
+ left: 0;
639
+ bottom: 0;
640
+ height: 2px;
641
+ max-width: 100%;
642
+ background: var(--accent);
643
+ /* Follow the chip's rounded bottom-left corner instead of poking out of it. */
644
+ border-bottom-left-radius: inherit;
645
+ pointer-events: none;
646
+ }
647
+
606
648
  /* Reveal a dot only once its session has reported activity (the tabs feature
607
649
  adds .wt-reports per dot; see paintStatusDot). Without it the dot — and the
608
650
  whole activity vocabulary for that tab — stays invisible. */
@@ -629,14 +671,44 @@
629
671
  opacity always multiplies onto its pseudos, so true independence requires
630
672
  animating disc COLORS, not element opacity. Server-side this is driven by the
631
673
  OSC 9;4 progress signal, so it tracks agent activity, not the user typing at
632
- the prompt. */
633
- :where(.wt-root) .wt-status-dot[data-status="working"] {
674
+ the prompt.
675
+
676
+ WORKING, WARNING AND FAILED SHARE THIS RULE, AND BOTH KEYFRAMES, VERBATIM.
677
+ OSC 9;4 models ONE progress indicator with several states (1/3 active,
678
+ 4 warning, 2 error), so it renders as one visual object whose HUE carries the
679
+ state — not three unrelated dots. Each state therefore sets --dot-color and
680
+ changes nothing else: same disc, same glow beat, same travelling wave, same
681
+ period. Do NOT give warning or failed a ring — the frozen hard ring is
682
+ input's exclusive meaning ("blocked, waiting on you"), and neither of these
683
+ asks the user to act (see the input rule). Hue alone would not be enough on
684
+ its own, which is why the three tokens are also spread across lightness
685
+ (measured OKLab L: warning 86.1%, working 71.9%, failed 57.7% — see the
686
+ load-bearing note in 00-tokens.css). */
687
+ :where(.wt-root) .wt-status-dot[data-status="working"],
688
+ :where(.wt-root) .wt-status-dot[data-status="warning"],
689
+ :where(.wt-root) .wt-status-dot[data-status="failed"] {
634
690
  position: relative;
635
- background: var(--status-working);
636
- border-color: var(--status-working);
691
+ background: var(--dot-color);
692
+ border-color: var(--dot-color);
637
693
  animation: wt-working-glow 1.2s ease-in-out infinite;
638
694
  }
639
695
 
696
+ /* The one difference between the three animated states. Kept as three
697
+ single-declaration rules so the shared paint above reads as literally shared:
698
+ a future change to the disc, the glow or the wave cannot land on one hue and
699
+ miss the other two. */
700
+ :where(.wt-root) .wt-status-dot[data-status="working"] {
701
+ --dot-color: var(--status-working);
702
+ }
703
+
704
+ :where(.wt-root) .wt-status-dot[data-status="warning"] {
705
+ --dot-color: var(--status-warning);
706
+ }
707
+
708
+ :where(.wt-root) .wt-status-dot[data-status="failed"] {
709
+ --dot-color: var(--status-failed);
710
+ }
711
+
640
712
  /* The ripple layer: a feathered radial BAND (not a hairline border) that starts
641
713
  hugging the disc's edge (inset -2px, the 9->13px box) and then travels
642
714
  outward, dissolving. The band is painted as a radial-gradient — soft on both
@@ -645,16 +717,18 @@
645
717
  which is what makes the wave visibly dissipate as it grows. transform +
646
718
  opacity only, so it composites without repaints (a gradient background is
647
719
  rasterized once, unlike an animated box-shadow spread, which repaints every
648
- frame). It exists only in the working state — no stale layer to pin when a
649
- dot changes status. (The switcher notification dot never shows working —
650
- input/done only — so its position: absolute placement never meets this
651
- rule's position: relative.)
720
+ frame). It exists only in the three animated states — no stale layer to pin
721
+ when a dot changes status. (The switcher notification dot never shows any of
722
+ them — input/done/crashed only — so its position: absolute placement never
723
+ meets this rule's position: relative.)
652
724
 
653
725
  The final scale is deliberately capped at today's 27px footprint: the mobile
654
726
  overview row (.wt-switcher-row-select) clips at overflow: hidden 12.5px from
655
727
  the dot's center, and a wave wider than ~25px picks up a visible straight
656
728
  edge there. Visibility comes from the band's weight and timing, not size. */
657
- :where(.wt-root) .wt-status-dot[data-status="working"]::after {
729
+ :where(.wt-root) .wt-status-dot[data-status="working"]::after,
730
+ :where(.wt-root) .wt-status-dot[data-status="warning"]::after,
731
+ :where(.wt-root) .wt-status-dot[data-status="failed"]::after {
658
732
  content: "";
659
733
  position: absolute;
660
734
  inset: -2px;
@@ -662,7 +736,7 @@
662
736
  background: radial-gradient(
663
737
  closest-side,
664
738
  transparent 44%,
665
- var(--status-working) 76%,
739
+ var(--dot-color) 76%,
666
740
  transparent 100%
667
741
  );
668
742
  animation: wt-working-ripple 1.2s linear infinite;
@@ -702,23 +776,46 @@
702
776
  opacity: 0.5;
703
777
  }
704
778
 
779
+ /* Crashed (a non-zero exit status, or a signal the program was not asked for;
780
+ a server-initiated end is "exited", so a routine restart never paints red):
781
+ a SOLID dot in the failed red, static.
782
+
783
+ Solid, not hollow. There is no "hollow means no process" invariant to honour —
784
+ idle is hollow with a perfectly live process — so hollowness would only say
785
+ "unremarkable", and a crash is the least unremarkable thing this vocabulary
786
+ reports. It uses the SAME token as failed, because "the program reported an
787
+ error" and "the program died badly" are one family, and it separates from
788
+ failed by MOTION: failed pulses and emits the travelling wave, crashed is
789
+ completely still (no glow, no wave, no ring — nothing clears it, so any
790
+ animation would nag forever). That is the same axis that already separates
791
+ working from done. Under prefers-reduced-motion the separation survives as
792
+ SHAPE: failed degrades to a ringed donut (see the reduced-motion block), while
793
+ crashed stays a bare solid disc. */
794
+ :where(.wt-root) .wt-status-dot[data-status="crashed"] {
795
+ background: var(--status-failed);
796
+ border-color: var(--status-failed);
797
+ }
798
+
705
799
  /* The disc's glow beat: base -> brighter -> base, then a rest until the next
706
800
  cycle. Colors are set at EVERY frame, which both drives the glow and pins
707
801
  the paint — a dot entering working in the same frame input/done ends can
708
802
  never re-pulse the previously rasterized paint (the hazard the old
709
803
  opacity-only pulse had to pin box-shadow against). Element opacity stays 1
710
- throughout; see the layer-independence note on the working rule. */
804
+ throughout; see the layer-independence note on the working rule.
805
+
806
+ --dot-color, not --status-working: this beat is shared verbatim by working,
807
+ warning and failed, each of which supplies its own hue. */
711
808
  @keyframes wt-working-glow {
712
809
  0%,
713
810
  70%,
714
811
  100% {
715
- background-color: var(--status-working);
716
- border-color: var(--status-working);
812
+ background-color: var(--dot-color);
813
+ border-color: var(--dot-color);
717
814
  }
718
815
 
719
816
  35% {
720
- background-color: color-mix(in oklch, var(--status-working) 78%, #fff);
721
- border-color: color-mix(in oklch, var(--status-working) 78%, #fff);
817
+ background-color: color-mix(in oklch, var(--dot-color) 78%, #fff);
818
+ border-color: color-mix(in oklch, var(--dot-color) 78%, #fff);
722
819
  }
723
820
  }
724
821
 
@@ -758,22 +855,27 @@
758
855
  }
759
856
  }
760
857
 
761
- /* Reduced-motion: drop the working motion (glow + ripple), keep color. */
858
+ /* Reduced-motion: drop the animated states' motion (glow + ripple), keep color.
859
+ All three degrade the same way, since they are one object with three hues. */
762
860
  @media (prefers-reduced-motion: reduce) {
763
- :where(.wt-root) .wt-status-dot[data-status="working"] {
861
+ :where(.wt-root) .wt-status-dot[data-status="working"],
862
+ :where(.wt-root) .wt-status-dot[data-status="warning"],
863
+ :where(.wt-root) .wt-status-dot[data-status="failed"] {
764
864
  animation: none;
765
- /* Motion is what separates working from input's frozen ping; with it
766
- removed, punch an inner hole (dot -> donut) and add a static ring so
767
- working differs from input (solid ringed disc) and done (bare disc) by
768
- shape, not hue alone (WCAG 1.4.1). The ring carries the same 30%
769
- weight as input's. */
865
+ /* Motion is what separates the animated states from input's frozen ping;
866
+ with it removed, punch an inner hole (dot -> donut) and add a static ring
867
+ so they differ from input (solid ringed disc) and done (bare disc) by
868
+ shape, not hue alone (WCAG 1.4.1). The ring carries the same 30% weight as
869
+ input's, from whichever hue the state supplied. */
770
870
  box-shadow:
771
871
  inset 0 0 0 2px var(--bg),
772
- 0 0 0 2px color-mix(in srgb, var(--status-working) 30%, transparent);
872
+ 0 0 0 2px color-mix(in srgb, var(--dot-color) 30%, transparent);
773
873
  }
774
874
 
775
875
  /* The ripple layer disappears entirely (not just paused mid-wave). */
776
- :where(.wt-root) .wt-status-dot[data-status="working"]::after {
876
+ :where(.wt-root) .wt-status-dot[data-status="working"]::after,
877
+ :where(.wt-root) .wt-status-dot[data-status="warning"]::after,
878
+ :where(.wt-root) .wt-status-dot[data-status="failed"]::after {
777
879
  content: none;
778
880
  }
779
881
  }
@@ -172,22 +172,20 @@
172
172
  min-width: 0;
173
173
  }
174
174
 
175
+ /* Ink-centring geometry, identical to .wt-tab-label: a symmetric ink allowance
176
+ the ellipsis clipping needs, plus a bottom margin of twice --label-ink-shift
177
+ that lifts the ink to the row centre. See 30-tabs.css for the derivation, for
178
+ why line-height must stay `normal` here, and for why this is margin arithmetic
179
+ over the bundled font rather than a text-box trim behind an @supports gate that
180
+ only one engine ever takes. */
175
181
  :where(.wt-root) .wt-switcher-label {
176
182
  flex: 1 1 auto;
177
183
  overflow: hidden;
178
184
  text-overflow: ellipsis;
179
185
  white-space: nowrap;
180
186
  text-align: left;
181
- }
182
-
183
- /* Ink-centering trim + its mandatory descender allowance, same as .wt-tab-label
184
- (see 30-tabs.css for why `ex`, why the padding is not optional, and why the
185
- pair is @supports-gated rather than left to fall back). */
186
- @supports (text-box: trim-both ex alphabetic) {
187
- :where(.wt-root) .wt-switcher-label {
188
- text-box: trim-both ex alphabetic;
189
- padding-block: 0.5em;
190
- }
187
+ padding-block: 0.125em;
188
+ margin-block: 0 calc(2 * var(--label-ink-shift));
191
189
  }
192
190
 
193
191
  /* Keyboard button: hidden until a keyboardToggle is wired; filled while the key
@@ -301,9 +299,12 @@
301
299
 
302
300
  /* Latest-wins background-tab notification dot on the switch button — the moved
303
301
  attention cue: the input colour when a background terminal needs input, the
304
- done colour when a background turn finished. Reuses the exact .wt-status-dot
305
- rules (30-tabs.css, --status-* tokens) so it matches the per-tab dots. Hidden
306
- until the tabs feature sets data-status; cleared when the list opens
302
+ done colour when a background turn finished, the failed red when a background
303
+ process crashed. Those three only — the rule the user set is "only states that
304
+ want the user, like a notification", so the ongoing progress states (working /
305
+ warning / failed) never raise it. Reuses the exact .wt-status-dot rules
306
+ (30-tabs.css, --status-* tokens) so it matches the per-tab dots. Hidden until
307
+ the tabs feature sets data-status; cleared when the list opens
307
308
  (expandSwitcher). */
308
309
  :where(.wt-root) .wt-switcher-switch-dot {
309
310
  position: absolute;
@@ -312,8 +313,9 @@
312
313
  }
313
314
 
314
315
  /* The dot carries the .wt-status-dot class, so its size, shape, and colour come
315
- straight from the shared .wt-status-dot[data-status="input"|"done"] rules
316
- (30-tabs.css) — the SAME dot colours every tab already uses, defined once.
316
+ straight from the shared .wt-status-dot[data-status="input"|"done"|"crashed"]
317
+ rules (30-tabs.css) — the SAME dot colours every tab already uses, defined
318
+ once. None of the three animates, so nothing here can pin a wave layer.
317
319
  .wt-status-dot is display:none by default; force it visible only while a
318
320
  notification is set (data-status present). */
319
321
  :where(.wt-root) .wt-switcher-switch-dot[data-status] {
@@ -407,6 +409,10 @@
407
409
  display: flex;
408
410
  align-items: center;
409
411
  gap: var(--sp-2);
412
+ /* Positioning context for this row's progress bar (.wt-progress-bar, defined
413
+ once with the shared dot vocabulary in 30-tabs.css), which rides the row's
414
+ own bottom edge. */
415
+ position: relative;
410
416
  min-height: var(--touch-target);
411
417
  /* Right padding reserves room for the close (x) overlaid inside the box. */
412
418
  padding: 0 var(--touch-target) 0 var(--sp-2);
@@ -435,21 +441,19 @@
435
441
  background: color-mix(in oklch, var(--tab-bg), var(--text) 15%);
436
442
  }
437
443
 
444
+ /* Ink-centring geometry, identical to .wt-tab-label: a symmetric ink allowance
445
+ the ellipsis clipping needs, plus a bottom margin of twice --label-ink-shift
446
+ that lifts the ink to the row centre. See 30-tabs.css for the derivation, for
447
+ why line-height must stay `normal` here, and for why this is margin arithmetic
448
+ over the bundled font rather than a text-box trim behind an @supports gate that
449
+ only one engine ever takes. */
438
450
  :where(.wt-root) .wt-switcher-row-label {
439
451
  flex: 1 1 auto;
440
452
  overflow: hidden;
441
453
  text-overflow: ellipsis;
442
454
  white-space: nowrap;
443
- }
444
-
445
- /* Ink-centering trim + its mandatory descender allowance, same as .wt-tab-label
446
- (see 30-tabs.css for why `ex`, why the padding is not optional, and why the
447
- pair is @supports-gated rather than left to fall back). */
448
- @supports (text-box: trim-both ex alphabetic) {
449
- :where(.wt-root) .wt-switcher-row-label {
450
- text-box: trim-both ex alphabetic;
451
- padding-block: 0.5em;
452
- }
455
+ padding-block: 0.125em;
456
+ margin-block: 0 calc(2 * var(--label-ink-shift));
453
457
  }
454
458
 
455
459
  /* The close sits flush at the row's right edge (no gap), vertically centered. */