@cplieger/web-terminal-ui 5.0.3 → 5.2.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,37 @@ 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,
147
+ shown only while one of those three progress states is the session's current
148
+ status. The number itself is announced in the tab's accessible name and drawn
149
+ nowhere: no terminal emulator puts a percentage next to a tab label, and a chip
150
+ that shrinks toward a 100px floor has no width to spare. It is likewise never
151
+ written to the browser document title, since one page title cannot represent
152
+ several sessions. An OSC 9 notification is posted as a browser
153
+ notification when the user is not already looking at that terminal (permission
154
+ is requested on a user gesture; a denial degrades silently to the tab dots).
155
+ Clicking one switches to the session that raised it: the Notifications API's
156
+ own click default already focuses the page, and the handler supplies the
157
+ in-page half. These are non-persistent notifications, so per the API the
158
+ constructor throws on most mobile browsers, where the notification degrades to
159
+ the tab dots.
139
160
 
140
161
  ### Options
141
162
 
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. |
163
+ | Option | Default | Purpose |
164
+ | -------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
165
+ | `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. |
166
+ | `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). |
167
+ | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
168
+ | `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. |
169
+ | `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. |
170
+ | `onFatalError` | _(built-in recovery)_ | Called with a `TerminalStartupFailure` after a fatal startup failure, in either phase (`feature-setup` or `kernel-init`); behavior below. |
171
+ | `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
172
 
152
173
  `createTerminal()` returns a handle: `focus()` re-focuses the terminal input
153
174
  (and opens the soft keyboard on touch); `send(bytes)` sends bytes to the active
@@ -157,6 +178,33 @@ screen without injecting keystrokes (send a redraw keystroke yourself if you
157
178
  want one, for example Ctrl+L); and `destroy()` tears every feature down and
158
179
  releases the kernel.
159
180
 
181
+ ### Themes that provably apply
182
+
183
+ `theme` is an open `Record<string, string>` — the kernel sets every key on the
184
+ terminal root verbatim — so a key this library renamed or retired becomes a live
185
+ declaration nothing reads: no error, just the library's neutral defaults where
186
+ your brand should be. The supported keys are therefore published as data:
187
+
188
+ ```ts
189
+ import { PUBLIC_THEME_TOKENS } from "@cplieger/web-terminal-ui/style-contract";
190
+ // or from the package root; the subpath imports nothing and touches no DOM,
191
+ // so a Node script can read it too.
192
+
193
+ for (const key of Object.keys(MY_THEME)) {
194
+ expect(PUBLIC_THEME_TOKENS).toContain(key); // your test, our list
195
+ }
196
+ ```
197
+
198
+ Every token on that list is guaranteed by this package's own suite to be both
199
+ declared AND read by a shipped rule, so setting it changes what renders. The
200
+ second half is the one you cannot check from outside: a token that is declared
201
+ but that no rule reads accepts your override and applies it to nothing. Anything
202
+ NOT on the list is internal and may be renamed without a release note.
203
+
204
+ `LOADING_OVERLAY_CLASSES` is published from the same module for the same reason:
205
+ your pre-JS overlay markup opts into `css/page.css`'s styling by class name, and
206
+ that markup usually lives in a static HTML file no compiler reads.
207
+
160
208
  ### Startup failures
161
209
 
162
210
  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,34 @@
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;
84
+ /* ...and the clip that keeps that bar inside the chip. A 2px line at
85
+ `left: 0; bottom: 0` has square ends, so without this it pokes out through
86
+ both rounded bottom corners and reads as running edge to edge. The clip is
87
+ applied to the PADDING box with the chip's inner radius (--radius-inner less
88
+ the 1px border), which is exactly the shape the bar must follow, and it is
89
+ what the other two chip sites already do (.wt-switcher-current,
90
+ .wt-switcher-row-select). Nothing else in the chip needs to escape: the
91
+ dot's ripple stays within the --sp-2 padding at its visible amplitude, and
92
+ the rename field's 2px/1px-offset focus ring sits well inside it. */
93
+ overflow: hidden;
78
94
  /* ONE height for every control in the strip: the chip, the "+" and the
79
95
  keyboard button all resolve to --touch-target, so a chip and a button can
80
96
  never disagree about how tall the row is. Before this the chip's height was
81
97
  whatever its tallest child happened to be, and the "+" hard-coded a COPY of
82
98
  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
99
+ for one row, which drifted the moment a child's box grew: the label's own
100
+ box once reached 31px, past the 24px close button, and the chip rendered
101
+ 49px tall next to a 42px "+" and a 44px keyboard button. (That growth came
102
+ from a text-box trim's descender padding being applied on the untrimmed
103
+ code path, which no longer exists -- the label's geometry is now one
104
+ unconditional rule; see .wt-tab-label. The two-sources-of-truth lesson is
105
+ what survives.) --touch-target is also the tap-target floor the rest of
87
106
  the chrome already respects, including this chip's mobile counterpart
88
107
  (.wt-switcher-current in 31-switcher.css, same declaration).
89
108
 
@@ -174,57 +193,58 @@
174
193
  }
175
194
 
176
195
  /* 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). */
196
+ (min-width: 0 lets a flex item shrink below its content width), and it owns the
197
+ ink-centring geometry the two switcher labels in 31-switcher.css share verbatim.
198
+
199
+ Centre the INK, not the line box. A font's metrics box reaches far higher above
200
+ the baseline than below it (the bundled font: 0.995em over, 0.250em under),
201
+ while the ink of a mixed-case title runs 0.7344em over (cap height) to 0.2188em
202
+ under (descender). Flex centring centres the BOX, which leaves the visible text
203
+ --label-ink-shift low — 0.1147em, 1.5px at this size — reading as
204
+ baseline-aligned with the activity dot's bottom edge instead of level with its
205
+ centre. Two declarations, one job, and a third that must NOT be added:
206
+
207
+ - padding-block is the ink allowance, not spacing. `overflow: hidden` (which
208
+ the ellipsis requires) clips at the padding box, and the line box clears the
209
+ tallest ink by only 0.6px, so an unpadded label shaves the top off every
210
+ acute accent and caron. 0.125em per side buys 2.2px of clearance at 13px, and
211
+ being symmetric it does not move the centred text.
212
+ - margin-block hangs TWICE the shift below the box. Flex centring centres the
213
+ MARGIN box, so a bottom-heavy margin lifts the border box by half of it --
214
+ exactly one shift -- and unlike padding it does not enlarge the clipping box.
215
+ - line-height stays at `normal`, deliberately. Setting it looks like the
216
+ responsible thing to do and it is the trap: `normal` is the ONE value whose
217
+ half-leading is zero by definition, which is what makes the baseline sit at
218
+ exactly the engine's ascent below the box top. Any other value leaves
219
+ (line-height - font box) / 2 to be halved and rounded, and Chromium floors
220
+ it -- measured with an explicit 1.25 the 14px switcher labels came out a
221
+ FULL pixel high (line box 17.5 under an 18px font box, so half-leading
222
+ -0.25 floored to -1) while the 13px strip label was fine, which is exactly
223
+ the kind of works-where-I-looked bug this rule exists to end.
224
+
225
+ What this deliberately does NOT use is `text-box: trim-both`, which all three
226
+ previous attempts were built on. It ships in Blink and WebKit only (Firefox has
227
+ it in Nightly behind layout.css.text-box.enabled), so it can only be applied
228
+ behind an @supports gate, and a gate means two engines render two different
229
+ geometries of which only the one the author is looking at ever gets tuned.
230
+ Measured on the gated version: Chromium centred to 0.02px while Firefox sat
231
+ 1.50px low with its baseline half a pixel BELOW the dot. The keyword vocabulary
232
+ is too coarse for this text besides -- `cap alphabetic` centres the cap band and
233
+ reads 1.14px low on lowercase, `ex alphabetic` centres the x-band and reads high
234
+ on capitals, and a mixed-case tab title wants neither edge.
235
+
236
+ The residual error is engines rounding ascent and descent to whole pixels, which
237
+ makes the ideal shift 0.127em at 13px and 0.099em at 14px; the one authored
238
+ 0.1147em is the unrounded truth and lands within 0.22px at both sizes, in every
239
+ engine, with no fork. */
178
240
  :where(.wt-root) .wt-tab-label {
179
241
  flex: 1 1 auto;
180
242
  min-width: 0;
181
243
  overflow: hidden;
182
244
  white-space: nowrap;
183
245
  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
- }
246
+ padding-block: 0.125em;
247
+ margin-block: 0 calc(2 * var(--label-ink-shift));
228
248
  }
229
249
 
230
250
  /* A little breathing room after the activity dot, before the label. */
@@ -569,9 +589,13 @@
569
589
  }
570
590
 
571
591
  /* ===== 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.
592
+ Eight-state, distinguishable without color (shape + motion, not hue alone),
593
+ per section 12 / WCAG 1.4.1: three ANIMATED progress states (working /
594
+ warning / failed — one object, three hues, spread across lightness so they
595
+ survive greyscale and deuteranopia), a frozen ringed disc (input), two static
596
+ discs (green done, red crashed), and two hollow ones (idle, dim exited).
597
+ The one .wt-status-dot vocabulary is shared by the desktop strip, the mobile
598
+ switcher, and the overview sheet rows.
575
599
 
576
600
  Revealed PER TAB: the dot is hidden until its own session reports activity
577
601
  (OSC 9;4 progress, or a classified OSC 9 notification), at which point the
@@ -595,6 +619,7 @@
595
619
  from the filled done/working/input states (WCAG 1.4.1). */
596
620
  background: transparent;
597
621
  border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
622
+
598
623
  /* Reset every transient visual owned by a status. In particular, a dot that
599
624
  leaves working must stop its pulse, and a dot that enters working must not
600
625
  carry the input ring or an exited opacity into the animated paint layer. */
@@ -603,6 +628,39 @@
603
628
  animation: none;
604
629
  }
605
630
 
631
+ /* ===== Determinate progress bar (OSC 9;4 percentage) =====
632
+ The second display mode the specs name for a progress report (iTerm2 calls it
633
+ a progress bar; ConEmu also puts the value on the taskbar, which is the
634
+ document-title prefix's job). One 2px line on the chip's bottom edge, shared
635
+ by all three chip sites (desktop strip chip, mobile active row, expanded list
636
+ row) because it ships in the one chipContent builder.
637
+
638
+ The ACCENT colour, not a per-state tint: the bar answers "how far", the dot
639
+ answers "in what state", and tinting the bar per state would say the same
640
+ thing twice while making a 2px line carry hue it cannot carry legibly.
641
+
642
+ Painted only while a percentage actually exists: the element carries the
643
+ `hidden` attribute otherwise (paintProgress), so an absent value (-1) renders
644
+ NO bar rather than a zero-width or empty one. Its width is the only inline
645
+ style — the percentage is data, not styling.
646
+
647
+ The rounded ENDS are the chip's job, not this rule's: each chip site clips its
648
+ own overflow (.wt-tab here, .wt-switcher-current and .wt-switcher-row-select in
649
+ 31-switcher.css), so the bar is cut to the chip's inner corner radius at both
650
+ ends. A radius on the bar itself cannot do that job — a 2px-tall box scales
651
+ every radius down to 2px under the CSS overlap rule, so it would trace a 2px
652
+ curve against the chip's --radius-inner one and still square off wherever the
653
+ percentage stops short of the edge. */
654
+ :where(.wt-root) .wt-progress-bar {
655
+ position: absolute;
656
+ left: 0;
657
+ bottom: 0;
658
+ height: 2px;
659
+ max-width: 100%;
660
+ background: var(--accent);
661
+ pointer-events: none;
662
+ }
663
+
606
664
  /* Reveal a dot only once its session has reported activity (the tabs feature
607
665
  adds .wt-reports per dot; see paintStatusDot). Without it the dot — and the
608
666
  whole activity vocabulary for that tab — stays invisible. */
@@ -629,14 +687,44 @@
629
687
  opacity always multiplies onto its pseudos, so true independence requires
630
688
  animating disc COLORS, not element opacity. Server-side this is driven by the
631
689
  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"] {
690
+ the prompt.
691
+
692
+ WORKING, WARNING AND FAILED SHARE THIS RULE, AND BOTH KEYFRAMES, VERBATIM.
693
+ OSC 9;4 models ONE progress indicator with several states (1/3 active,
694
+ 4 warning, 2 error), so it renders as one visual object whose HUE carries the
695
+ state — not three unrelated dots. Each state therefore sets --dot-color and
696
+ changes nothing else: same disc, same glow beat, same travelling wave, same
697
+ period. Do NOT give warning or failed a ring — the frozen hard ring is
698
+ input's exclusive meaning ("blocked, waiting on you"), and neither of these
699
+ asks the user to act (see the input rule). Hue alone would not be enough on
700
+ its own, which is why the three tokens are also spread across lightness
701
+ (measured OKLab L: warning 86.1%, working 71.9%, failed 57.7% — see the
702
+ load-bearing note in 00-tokens.css). */
703
+ :where(.wt-root) .wt-status-dot[data-status="working"],
704
+ :where(.wt-root) .wt-status-dot[data-status="warning"],
705
+ :where(.wt-root) .wt-status-dot[data-status="failed"] {
634
706
  position: relative;
635
- background: var(--status-working);
636
- border-color: var(--status-working);
707
+ background: var(--dot-color);
708
+ border-color: var(--dot-color);
637
709
  animation: wt-working-glow 1.2s ease-in-out infinite;
638
710
  }
639
711
 
712
+ /* The one difference between the three animated states. Kept as three
713
+ single-declaration rules so the shared paint above reads as literally shared:
714
+ a future change to the disc, the glow or the wave cannot land on one hue and
715
+ miss the other two. */
716
+ :where(.wt-root) .wt-status-dot[data-status="working"] {
717
+ --dot-color: var(--status-working);
718
+ }
719
+
720
+ :where(.wt-root) .wt-status-dot[data-status="warning"] {
721
+ --dot-color: var(--status-warning);
722
+ }
723
+
724
+ :where(.wt-root) .wt-status-dot[data-status="failed"] {
725
+ --dot-color: var(--status-failed);
726
+ }
727
+
640
728
  /* The ripple layer: a feathered radial BAND (not a hairline border) that starts
641
729
  hugging the disc's edge (inset -2px, the 9->13px box) and then travels
642
730
  outward, dissolving. The band is painted as a radial-gradient — soft on both
@@ -645,16 +733,18 @@
645
733
  which is what makes the wave visibly dissipate as it grows. transform +
646
734
  opacity only, so it composites without repaints (a gradient background is
647
735
  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.)
736
+ frame). It exists only in the three animated states — no stale layer to pin
737
+ when a dot changes status. (The switcher notification dot never shows any of
738
+ them — input/done/crashed only — so its position: absolute placement never
739
+ meets this rule's position: relative.)
652
740
 
653
741
  The final scale is deliberately capped at today's 27px footprint: the mobile
654
742
  overview row (.wt-switcher-row-select) clips at overflow: hidden 12.5px from
655
743
  the dot's center, and a wave wider than ~25px picks up a visible straight
656
744
  edge there. Visibility comes from the band's weight and timing, not size. */
657
- :where(.wt-root) .wt-status-dot[data-status="working"]::after {
745
+ :where(.wt-root) .wt-status-dot[data-status="working"]::after,
746
+ :where(.wt-root) .wt-status-dot[data-status="warning"]::after,
747
+ :where(.wt-root) .wt-status-dot[data-status="failed"]::after {
658
748
  content: "";
659
749
  position: absolute;
660
750
  inset: -2px;
@@ -662,7 +752,7 @@
662
752
  background: radial-gradient(
663
753
  closest-side,
664
754
  transparent 44%,
665
- var(--status-working) 76%,
755
+ var(--dot-color) 76%,
666
756
  transparent 100%
667
757
  );
668
758
  animation: wt-working-ripple 1.2s linear infinite;
@@ -702,23 +792,46 @@
702
792
  opacity: 0.5;
703
793
  }
704
794
 
795
+ /* Crashed (a non-zero exit status, or a signal the program was not asked for;
796
+ a server-initiated end is "exited", so a routine restart never paints red):
797
+ a SOLID dot in the failed red, static.
798
+
799
+ Solid, not hollow. There is no "hollow means no process" invariant to honour —
800
+ idle is hollow with a perfectly live process — so hollowness would only say
801
+ "unremarkable", and a crash is the least unremarkable thing this vocabulary
802
+ reports. It uses the SAME token as failed, because "the program reported an
803
+ error" and "the program died badly" are one family, and it separates from
804
+ failed by MOTION: failed pulses and emits the travelling wave, crashed is
805
+ completely still (no glow, no wave, no ring — nothing clears it, so any
806
+ animation would nag forever). That is the same axis that already separates
807
+ working from done. Under prefers-reduced-motion the separation survives as
808
+ SHAPE: failed degrades to a ringed donut (see the reduced-motion block), while
809
+ crashed stays a bare solid disc. */
810
+ :where(.wt-root) .wt-status-dot[data-status="crashed"] {
811
+ background: var(--status-failed);
812
+ border-color: var(--status-failed);
813
+ }
814
+
705
815
  /* The disc's glow beat: base -> brighter -> base, then a rest until the next
706
816
  cycle. Colors are set at EVERY frame, which both drives the glow and pins
707
817
  the paint — a dot entering working in the same frame input/done ends can
708
818
  never re-pulse the previously rasterized paint (the hazard the old
709
819
  opacity-only pulse had to pin box-shadow against). Element opacity stays 1
710
- throughout; see the layer-independence note on the working rule. */
820
+ throughout; see the layer-independence note on the working rule.
821
+
822
+ --dot-color, not --status-working: this beat is shared verbatim by working,
823
+ warning and failed, each of which supplies its own hue. */
711
824
  @keyframes wt-working-glow {
712
825
  0%,
713
826
  70%,
714
827
  100% {
715
- background-color: var(--status-working);
716
- border-color: var(--status-working);
828
+ background-color: var(--dot-color);
829
+ border-color: var(--dot-color);
717
830
  }
718
831
 
719
832
  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);
833
+ background-color: color-mix(in oklch, var(--dot-color) 78%, #fff);
834
+ border-color: color-mix(in oklch, var(--dot-color) 78%, #fff);
722
835
  }
723
836
  }
724
837
 
@@ -758,22 +871,27 @@
758
871
  }
759
872
  }
760
873
 
761
- /* Reduced-motion: drop the working motion (glow + ripple), keep color. */
874
+ /* Reduced-motion: drop the animated states' motion (glow + ripple), keep color.
875
+ All three degrade the same way, since they are one object with three hues. */
762
876
  @media (prefers-reduced-motion: reduce) {
763
- :where(.wt-root) .wt-status-dot[data-status="working"] {
877
+ :where(.wt-root) .wt-status-dot[data-status="working"],
878
+ :where(.wt-root) .wt-status-dot[data-status="warning"],
879
+ :where(.wt-root) .wt-status-dot[data-status="failed"] {
764
880
  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. */
881
+ /* Motion is what separates the animated states from input's frozen ping;
882
+ with it removed, punch an inner hole (dot -> donut) and add a static ring
883
+ so they differ from input (solid ringed disc) and done (bare disc) by
884
+ shape, not hue alone (WCAG 1.4.1). The ring carries the same 30% weight as
885
+ input's, from whichever hue the state supplied. */
770
886
  box-shadow:
771
887
  inset 0 0 0 2px var(--bg),
772
- 0 0 0 2px color-mix(in srgb, var(--status-working) 30%, transparent);
888
+ 0 0 0 2px color-mix(in srgb, var(--dot-color) 30%, transparent);
773
889
  }
774
890
 
775
891
  /* The ripple layer disappears entirely (not just paused mid-wave). */
776
- :where(.wt-root) .wt-status-dot[data-status="working"]::after {
892
+ :where(.wt-root) .wt-status-dot[data-status="working"]::after,
893
+ :where(.wt-root) .wt-status-dot[data-status="warning"]::after,
894
+ :where(.wt-root) .wt-status-dot[data-status="failed"]::after {
777
895
  content: none;
778
896
  }
779
897
  }