@jmcombs/pi-steward 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/core/disconnected-source.ts +110 -0
  4. package/core/drift.ts +247 -0
  5. package/core/format.ts +317 -0
  6. package/core/host-metrics.ts +121 -0
  7. package/core/llama-config.ts +72 -0
  8. package/core/llama-connection.ts +215 -0
  9. package/core/llama-models.ts +261 -0
  10. package/core/llama-slots.ts +104 -0
  11. package/core/llama-source.ts +1523 -0
  12. package/core/log-parse.ts +440 -0
  13. package/core/model-color.ts +59 -0
  14. package/core/select.ts +2923 -0
  15. package/core/slot-activity.ts +658 -0
  16. package/core/source.ts +84 -0
  17. package/core/state.ts +609 -0
  18. package/core/status-widget.ts +222 -0
  19. package/core/temperature.ts +149 -0
  20. package/core/types.ts +431 -0
  21. package/index.ts +503 -0
  22. package/package.json +51 -0
  23. package/server/api.ts +216 -0
  24. package/server/assets.ts +198 -0
  25. package/server/config-wiring.ts +490 -0
  26. package/server/drift-probe.ts +150 -0
  27. package/server/host-collector.ts +272 -0
  28. package/server/index.ts +228 -0
  29. package/server/log-tailer.ts +432 -0
  30. package/server/service-control.ts +337 -0
  31. package/server/service-probe.ts +71 -0
  32. package/server/steward-config.ts +430 -0
  33. package/setup/init-prompt.ts +214 -0
  34. package/setup/steward-setup.d.mts +16 -0
  35. package/setup/steward-setup.mjs +1398 -0
  36. package/ui/components/console.ts +511 -0
  37. package/ui/components/gauges.ts +120 -0
  38. package/ui/components/metrics.ts +63 -0
  39. package/ui/components/models.ts +296 -0
  40. package/ui/components/service.ts +358 -0
  41. package/ui/components/slots.ts +114 -0
  42. package/ui/components/sparkline.ts +59 -0
  43. package/ui/components/toolbar.ts +211 -0
  44. package/ui/dom.ts +120 -0
  45. package/ui/favicon.svg +17 -0
  46. package/ui/index.html +34 -0
  47. package/ui/main.ts +678 -0
  48. package/ui/steward.css +2008 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * The rail's MODELS block.
3
+ *
4
+ * A card is a filter control and a container for a second control at once, so
5
+ * it cannot be a `<button>` — it is a `role="button"` with the keyboard
6
+ * handling written out, and the Load/Unload button inside it keeps both the
7
+ * click and the key events it handles natively away from the card.
8
+ *
9
+ * The block header carries a single info legend for the whole list: a real
10
+ * button that toggles a definitions panel, one entry per card field label
11
+ * (`Quant`, `Context`, `KV Cache`, …), so the compact labeled grid can be read
12
+ * once and understood.
13
+ */
14
+
15
+ import type { ModelCardVm, PillVm } from "../../core/select.js";
16
+ import type { ModelAction } from "../../core/types.js";
17
+ import type { View } from "../dom.js";
18
+ import { el, setAttr, setText, setVar, syncRows } from "../dom.js";
19
+
20
+ export interface ModelsVm {
21
+ models: ModelCardVm[];
22
+ allLogsPill: PillVm;
23
+ }
24
+
25
+ export interface ModelsHandlers {
26
+ onFilterModel: (modelId: string) => void;
27
+ onShowAllLogs: () => void;
28
+ onModelAction: (modelId: string, action: ModelAction) => void;
29
+ }
30
+
31
+ /** One labeled cell of the body grid: its value span, patched per repaint. */
32
+ interface FieldCell {
33
+ root: HTMLElement;
34
+ label: HTMLElement;
35
+ value: HTMLElement;
36
+ }
37
+
38
+ interface ModelRow {
39
+ root: HTMLElement;
40
+ dot: HTMLElement;
41
+ name: HTMLElement;
42
+ /** The seven body cells, in the fixed order the view model lists them. */
43
+ cells: FieldCell[];
44
+ button: HTMLButtonElement;
45
+ vm: ModelCardVm | null;
46
+ }
47
+
48
+ /** Rows are recycled rather than keyed, so their ids only have to be unique. */
49
+ let rowSeq = 0;
50
+
51
+ /** How many labeled cells a card body always has, so the grid can be built once. */
52
+ const FIELD_COUNT = 7;
53
+
54
+ function createFieldCell(): FieldCell {
55
+ const label = el("span", { class: "model-card__label" });
56
+ const value = el("span", { class: "model-card__value" });
57
+ return {
58
+ root: el("div", { class: "model-card__field", children: [label, value] }),
59
+ label,
60
+ value,
61
+ };
62
+ }
63
+
64
+ function createRow(handlers: ModelsHandlers): ModelRow {
65
+ rowSeq += 1;
66
+ const prefix = `steward-model-${rowSeq}`;
67
+ const dot = el("span", { class: "model-card__dot" });
68
+ const name = el("span", { class: "model-card__name", attrs: { id: `${prefix}-name` } });
69
+ const cells = Array.from({ length: FIELD_COUNT }, createFieldCell);
70
+ // The body grid is named as one group: the label set is identical on every
71
+ // card, so a screen reader reading the whole grid's text (label + value, seven
72
+ // times) is exactly what distinguishes this card, and no per-field id juggling
73
+ // is needed as the values change.
74
+ const fields = el("div", {
75
+ class: "model-card__fields",
76
+ attrs: { id: `${prefix}-fields` },
77
+ children: cells.map((cell) => cell.root),
78
+ });
79
+
80
+ const row: ModelRow = {
81
+ root: el("div", { class: "model-card" }),
82
+ dot,
83
+ name,
84
+ cells,
85
+ button: el("button", { class: "btn btn--filled btn--sm", attrs: { type: "button" } }),
86
+ vm: null,
87
+ };
88
+
89
+ row.button.addEventListener("click", (event) => {
90
+ // Without this the card underneath would also take the click and toggle
91
+ // the log filter every time the operator loads a model.
92
+ event.stopPropagation();
93
+ if (row.vm !== null) handlers.onModelAction(row.vm.id, row.vm.buttonAction);
94
+ });
95
+
96
+ const filter = (): void => {
97
+ if (row.vm !== null) handlers.onFilterModel(row.vm.id);
98
+ };
99
+ row.root.addEventListener("click", filter);
100
+ row.root.addEventListener("keydown", (event) => {
101
+ if (!(event instanceof KeyboardEvent)) return;
102
+ // Enter and Space belong to the Load/Unload button while it holds focus:
103
+ // taking them here would preventDefault() its native activation and filter
104
+ // the log instead of loading the model.
105
+ if (event.target !== row.root) return;
106
+ // A held key repeats; a real button would still have fired exactly once.
107
+ if (event.repeat) return;
108
+ if (event.key !== "Enter" && event.key !== " ") return;
109
+ event.preventDefault();
110
+ filter();
111
+ });
112
+
113
+ setAttr(row.root, "role", "button");
114
+ setAttr(row.root, "tabindex", "0");
115
+ // The card is named from its own contents: the name and the body grid as a
116
+ // whole. The label set is fixed and identical on every card, so this is stamped
117
+ // once here rather than rebuilt each repaint.
118
+ setAttr(row.root, "aria-labelledby", `${prefix}-name ${prefix}-fields`);
119
+ // The Load/Unload button rides the header row with the name — the one row every
120
+ // card always has, and the only place a transition is announced — so it sits in
121
+ // the same place independent of the labeled grid below it.
122
+ row.root.append(
123
+ el("div", {
124
+ class: "model-card__top",
125
+ children: [el("div", { class: "model-card__title", children: [dot, name] }), row.button],
126
+ }),
127
+ fields,
128
+ );
129
+ return row;
130
+ }
131
+
132
+ /** The header info legend: one entry per card field, keyed by its exact label. */
133
+ const LEGEND_TERMS: readonly { term: string; def: string }[] = [
134
+ {
135
+ term: "Quant",
136
+ def: "Weight quantization: how compressed the model's weights are. Fewer bits = smaller and faster, slightly less precise. 16-bit (F16) is uncompressed. Read from the loaded file; n/a until then.",
137
+ },
138
+ {
139
+ term: "Size",
140
+ def: "On-disk size, and roughly the VRAM it needs once loaded. Known only while loaded.",
141
+ },
142
+ {
143
+ term: "Context",
144
+ def: "Tokens each request slot gets: the loaded context window divided across the parallel slots (total ÷ slots). Known only while loaded.",
145
+ },
146
+ {
147
+ term: "GPU Layers",
148
+ def: "Transformer layers offloaded to the GPU (-ngl); more on the GPU is faster, 99 means all. This is the requested value — the effective count is never reported, so it reads n/a unless it was pinned at launch.",
149
+ },
150
+ {
151
+ term: "Flash",
152
+ def: "Flash Attention — an optimized attention kernel: faster, less memory. On / Off / Auto. Can stay Auto even loaded — the resolved value isn't reported back.",
153
+ },
154
+ {
155
+ term: "KV Cache",
156
+ def: "Precision of the runtime attention cache — the per-request memory that grows with context, separate from the weight size. 8-bit is compact; 16-bit is the default.",
157
+ },
158
+ {
159
+ term: "Type",
160
+ def: "Whether the model generates text (Generative) or produces embeddings (Embedder). Read from the router's modalities, so it's known even while unloaded.",
161
+ },
162
+ ];
163
+
164
+ /** localStorage key for whether the legend is left open across a page session. */
165
+ const LEGEND_STORAGE_KEY = "steward.legend";
166
+
167
+ /** Reads the remembered legend state; storage may be unavailable or blocked. */
168
+ function readLegendOpen(): boolean {
169
+ try {
170
+ return globalThis.localStorage?.getItem(LEGEND_STORAGE_KEY) === "1";
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /** Persists the legend state; a failure just means it is not remembered. */
177
+ function writeLegendOpen(open: boolean): void {
178
+ try {
179
+ globalThis.localStorage?.setItem(LEGEND_STORAGE_KEY, open ? "1" : "0");
180
+ } catch {
181
+ // No storage (private mode, disabled): the in-memory state still holds.
182
+ }
183
+ }
184
+
185
+ export function createModelsBlock(handlers: ModelsHandlers): View<ModelsVm> {
186
+ const rows: ModelRow[] = [];
187
+ const list = el("div", { class: "model-list" });
188
+ const pill = el("button", {
189
+ class: "pill",
190
+ attrs: { type: "button" },
191
+ text: "all logs",
192
+ on: { click: handlers.onShowAllLogs },
193
+ });
194
+
195
+ const panelId = "steward-models-legend";
196
+ const panel = el("div", {
197
+ class: "legend-panel",
198
+ attrs: { id: panelId, role: "group", "aria-label": "Field definitions", hidden: true },
199
+ children: [
200
+ el("dl", {
201
+ class: "legend-list",
202
+ children: LEGEND_TERMS.flatMap(({ term, def }) => [
203
+ el("dt", { class: "legend-term", text: term }),
204
+ el("dd", { class: "legend-def", text: def }),
205
+ ]),
206
+ }),
207
+ ],
208
+ });
209
+
210
+ // A real button, not a hover affordance: it must be reachable and toggle-able
211
+ // from the keyboard and legible to a screen reader, which a tooltip is not.
212
+ let legendOpen = readLegendOpen();
213
+ const legendToggle = el("button", {
214
+ class: "legend-toggle",
215
+ text: "ⓘ",
216
+ attrs: {
217
+ type: "button",
218
+ "aria-label": "What these values mean",
219
+ "aria-controls": panelId,
220
+ // A string, not a boolean: `aria-expanded` is an enumerated ARIA value
221
+ // ("true"/"false"), not an HTML boolean attribute, and setAttr would drop a
222
+ // `false` entirely — leaving the state unspoken and the open-state style dead.
223
+ "aria-expanded": String(legendOpen),
224
+ },
225
+ });
226
+
227
+ const syncLegend = (): void => {
228
+ setAttr(legendToggle, "aria-expanded", String(legendOpen));
229
+ // `hidden` gives the panel zero height when collapsed, so it never pushes the
230
+ // cards down the rail until the operator asks for it.
231
+ setAttr(panel, "hidden", !legendOpen);
232
+ };
233
+ legendToggle.addEventListener("click", () => {
234
+ legendOpen = !legendOpen;
235
+ writeLegendOpen(legendOpen);
236
+ syncLegend();
237
+ });
238
+ syncLegend();
239
+
240
+ const root = el("section", {
241
+ class: "rail__block rail__block--models",
242
+ attrs: { "aria-labelledby": "steward-models-title" },
243
+ children: [
244
+ el("div", {
245
+ class: "block__head",
246
+ children: [
247
+ el("h2", { class: "eyebrow", text: "Models", attrs: { id: "steward-models-title" } }),
248
+ el("div", { class: "block__head-controls", children: [legendToggle, pill] }),
249
+ ],
250
+ }),
251
+ panel,
252
+ list,
253
+ ],
254
+ });
255
+
256
+ return {
257
+ el: root,
258
+ update(vm) {
259
+ setVar(pill, "bg", vm.allLogsPill.background);
260
+ setVar(pill, "fg", vm.allLogsPill.color);
261
+ setVar(pill, "bd", vm.allLogsPill.borderColor);
262
+ setAttr(pill, "aria-pressed", String(vm.allLogsPill.active));
263
+
264
+ syncRows(list, rows, vm.models.length, () => createRow(handlers));
265
+ vm.models.forEach((model, index) => {
266
+ const row = rows[index];
267
+ if (row === undefined) return;
268
+ row.vm = model;
269
+ setVar(row.root, "bg", model.cardBackground);
270
+ setVar(row.root, "bd", model.cardBorder);
271
+ setAttr(row.root, "aria-pressed", String(model.selected));
272
+ setAttr(row.root, "title", `Filter the log to ${model.short}`);
273
+ setVar(row.dot, "model-color", model.color);
274
+ setText(row.name, model.short);
275
+
276
+ // The label set and order are fixed, so cell i always renders field i;
277
+ // an `n/a` value carries `data-na="true"` so the stylesheet dims it while
278
+ // the label beside it stays at full strength.
279
+ model.fields.forEach((field, fieldIndex) => {
280
+ const cell = row.cells[fieldIndex];
281
+ if (cell === undefined) return;
282
+ setText(cell.label, `${field.label}:`);
283
+ setText(cell.value, field.value);
284
+ setAttr(cell.value, "data-na", String(field.na));
285
+ });
286
+
287
+ setText(row.button, model.buttonLabel);
288
+ setVar(row.button, "bg", model.buttonBackground);
289
+ setVar(row.button, "fg", model.buttonColor);
290
+ setVar(row.button, "bd", model.buttonBorder);
291
+ setAttr(row.button, "disabled", model.pending);
292
+ setAttr(row.button, "aria-label", `${model.buttonLabel} ${model.short}`);
293
+ });
294
+ },
295
+ };
296
+ }
@@ -0,0 +1,358 @@
1
+ /**
2
+ * The rail's SERVICE block — the "Steward" box.
3
+ *
4
+ * Three stacked zones: the brand lockup, the control row, and the router facts
5
+ * folded in from what used to be a separate CONFIG block. The status readout —
6
+ * `started` / `stopped` / `not connected` — is a chip riding in the lockup row
7
+ * beside the wordmark, not a zone of its own: it is the one thing in the block
8
+ * that cannot be acted on, so it does not get the block's heaviest element.
9
+ *
10
+ * The chip is deliberately not a button: it reports state and nothing more, so
11
+ * it is a `role="status"` region that a screen reader announces when the state
12
+ * changes. Its state rides a SHAPE (`data-state` → filled disc, ring, dotted
13
+ * ring) as well as a hue, so it survives a monochrome screen.
14
+ *
15
+ * The control row holds only actions this machine actually has a consented
16
+ * command for; with none, one setup affordance takes its place. Stop and
17
+ * restart never fire from a single click — they open an inline confirm strip
18
+ * that names what will be unloaded, moves focus to Cancel, and closes on Esc.
19
+ * A command that failed leaves an alert here rather than nothing at all.
20
+ */
21
+
22
+ import type { DriftNoticeVm, ServiceControlVm, ServiceVm } from "../../core/select.js";
23
+ import type { ServiceAction } from "../../core/types.js";
24
+ import type { View } from "../dom.js";
25
+ import { el, setAttr, setText, setVar, svg, syncRows } from "../dom.js";
26
+
27
+ export interface ServiceHandlers {
28
+ onToggleTheme: () => void;
29
+ /** Runs an action now: a non-disruptive one, or one already confirmed. */
30
+ onService: (action: ServiceAction) => void;
31
+ /** Asks for the confirm strip before a disruptive action. */
32
+ onConfirmService: (action: ServiceAction) => void;
33
+ /** Dismisses the confirm strip without acting. */
34
+ onCancelService: () => void;
35
+ /**
36
+ * Quiets the drift notice for this session. It is keyed by what drifted, so
37
+ * the notice returns on a reload and the moment the mismatch changes.
38
+ */
39
+ onDismissDrift: (key: string) => void;
40
+ }
41
+
42
+ /** One router fact: `listen 127.0.0.1:8080`. Patched per repaint. */
43
+ interface FactRow {
44
+ root: HTMLElement;
45
+ key: HTMLElement;
46
+ value: HTMLElement;
47
+ }
48
+
49
+ function createFactRow(): FactRow {
50
+ const key = el("span", { class: "config-row__key" });
51
+ const value = el("span", { class: "config-row__value" });
52
+ return { root: el("div", { class: "config-row", children: [key, value] }), key, value };
53
+ }
54
+
55
+ /**
56
+ * One control button. The action and whether it needs confirming live on the
57
+ * record rather than in the closure, so a row can be reused across repaints
58
+ * when the available set changes without rebinding its listener.
59
+ */
60
+ interface ControlRow {
61
+ root: HTMLButtonElement;
62
+ action: ServiceAction;
63
+ confirms: boolean;
64
+ }
65
+
66
+ /** The Steward mark: a serving dome over two trays, the lower one faded. */
67
+ function mark(): SVGElement {
68
+ return svg(
69
+ "svg",
70
+ {
71
+ width: "20",
72
+ height: "20",
73
+ viewBox: "0 0 40 40",
74
+ fill: "none",
75
+ "aria-hidden": "true",
76
+ class: "lockup__mark",
77
+ focusable: "false",
78
+ },
79
+ [
80
+ svg("path", {
81
+ d: "M8 17C8 10.9 13.4 6 20 6s12 4.9 12 11",
82
+ stroke: "currentColor",
83
+ "stroke-width": "4",
84
+ "stroke-linecap": "round",
85
+ }),
86
+ svg("rect", { x: "4", y: "21", width: "32", height: "5", rx: "2.5", fill: "currentColor" }),
87
+ svg("rect", {
88
+ x: "4",
89
+ y: "29.6",
90
+ width: "32",
91
+ height: "5",
92
+ rx: "2.5",
93
+ fill: "currentColor",
94
+ opacity: "0.55",
95
+ }),
96
+ ],
97
+ );
98
+ }
99
+
100
+ export function createServiceBlock(handlers: ServiceHandlers): View<ServiceVm> {
101
+ const statusDot = el("span", { class: "service__status-dot", attrs: { "aria-hidden": "true" } });
102
+ const statusLabel = el("span", { class: "service__status-label" });
103
+ // Not a <button>: it reports state and nothing more, so it takes no hover and
104
+ // no click. `tabindex="-1"` keeps it out of the tab order while still letting
105
+ // the confirm flow park focus here when the button it came from went inert.
106
+ const status = el("div", {
107
+ class: "service__status",
108
+ attrs: { role: "status", "aria-live": "polite", tabindex: "-1" },
109
+ children: [statusDot, statusLabel],
110
+ });
111
+ const theme = el("button", {
112
+ class: "btn btn--icon",
113
+ attrs: { type: "button" },
114
+ on: { click: handlers.onToggleTheme },
115
+ });
116
+
117
+ const controls = el("div", {
118
+ class: "service__controls",
119
+ attrs: { role: "group", "aria-label": "Service control" },
120
+ });
121
+ const controlRows: ControlRow[] = [];
122
+ function createControlRow(): ControlRow {
123
+ const row: ControlRow = {
124
+ root: el("button", { class: "btn btn--sm service__control", attrs: { type: "button" } }),
125
+ action: "start",
126
+ confirms: false,
127
+ };
128
+ row.root.addEventListener("click", () => {
129
+ if (row.confirms) handlers.onConfirmService(row.action);
130
+ else handlers.onService(row.action);
131
+ });
132
+ return row;
133
+ }
134
+
135
+ // Not a button: the dashboard cannot run a Pi command, and a control that
136
+ // does nothing is worse than a sentence that says what to do.
137
+ const setupLabel = el("p", { class: "service__setup-label" });
138
+ const setupDetail = el("p", { class: "service__setup-detail" });
139
+ const setupCommand = el("code", { class: "service__setup-command" });
140
+ const setup = el("div", {
141
+ class: "service__setup",
142
+ attrs: { hidden: true },
143
+ children: [setupLabel, setupDetail, setupCommand],
144
+ });
145
+
146
+ const confirmText = el("p", { class: "service__confirm-text" });
147
+ const confirmCancel = el("button", {
148
+ class: "btn btn--sm",
149
+ attrs: { type: "button" },
150
+ on: { click: handlers.onCancelService },
151
+ });
152
+ const confirmAccept = el("button", {
153
+ class: "btn btn--sm service__control",
154
+ attrs: { type: "button", "data-tone": "danger" },
155
+ });
156
+ let confirmAction: ServiceAction | null = null;
157
+ confirmAccept.addEventListener("click", () => {
158
+ if (confirmAction !== null) handlers.onService(confirmAction);
159
+ });
160
+ const confirm = el("div", {
161
+ class: "service__confirm",
162
+ attrs: { role: "group", "aria-label": "Confirm service action", hidden: true },
163
+ children: [
164
+ confirmText,
165
+ el("div", { class: "service__confirm-row", children: [confirmCancel, confirmAccept] }),
166
+ ],
167
+ });
168
+ // Esc backs out from anywhere inside the strip — focus is on Cancel when it
169
+ // opens, so the escape hatch is one key away without a pointer.
170
+ confirm.addEventListener("keydown", (event) => {
171
+ if (event instanceof KeyboardEvent && event.key === "Escape") handlers.onCancelService();
172
+ });
173
+
174
+ // A failed command is an operator-relevant event, not a status update, so it
175
+ // announces assertively rather than waiting behind the polite region. It is
176
+ // focusable programmatically (never by Tab) so the confirm flow can hand
177
+ // focus to the outcome when the button that started it has gone inert.
178
+ const notice = el("p", {
179
+ class: "service__notice",
180
+ attrs: { role: "alert", hidden: true, tabindex: "-1" },
181
+ });
182
+
183
+ // The drift notice. It sits directly under the status readout — above the
184
+ // controls and the router facts it contradicts — because a machine that no
185
+ // longer matches `steward.json` is the first thing about this block that is
186
+ // not true. It carries no `aria-live` of its own: `main.ts` announces it once
187
+ // through the page's polite status region, and a second live region would
188
+ // repeat every line on every 1.6 s poll.
189
+ const driftTitle = el("p", { class: "service__drift-title" });
190
+ const driftDismiss = el("button", {
191
+ class: "btn btn--sm service__drift-dismiss",
192
+ attrs: { type: "button" },
193
+ });
194
+ const driftList = el("ul", { class: "service__drift-list" });
195
+ const driftRows: { root: HTMLElement }[] = [];
196
+ const driftFix = el("p", { class: "service__drift-fix" });
197
+ const drift = el("section", {
198
+ class: "service__drift",
199
+ attrs: { hidden: true },
200
+ children: [
201
+ el("div", {
202
+ class: "service__drift-head",
203
+ children: [
204
+ // The tone is carried by the word "drift", the icon, and the border —
205
+ // never by colour alone.
206
+ el("span", {
207
+ class: "service__drift-icon",
208
+ text: "!",
209
+ attrs: { "aria-hidden": "true" },
210
+ }),
211
+ driftTitle,
212
+ driftDismiss,
213
+ ],
214
+ }),
215
+ driftList,
216
+ driftFix,
217
+ ],
218
+ });
219
+ let driftKey: string | null = null;
220
+ driftDismiss.addEventListener("click", () => {
221
+ if (driftKey !== null) handlers.onDismissDrift(driftKey);
222
+ });
223
+
224
+ const facts = el("div", { class: "config-list service__facts" });
225
+ const rows: FactRow[] = [];
226
+
227
+ const root = el("section", {
228
+ class: "rail__block rail__block--service",
229
+ // The block has no eyebrow to point at, and the lockup is the page's h1
230
+ // rather than this region's heading.
231
+ attrs: { "aria-label": "Service" },
232
+ children: [
233
+ // The chip and the theme control ride the lockup's dead space rather than
234
+ // taking a 36px row of their own beneath it.
235
+ el("div", {
236
+ class: "lockup",
237
+ children: [mark(), el("h1", { class: "lockup__name", text: "Steward" }), status, theme],
238
+ }),
239
+ drift,
240
+ controls,
241
+ setup,
242
+ confirm,
243
+ notice,
244
+ facts,
245
+ ],
246
+ });
247
+
248
+ /** Patches the drift notice, or hides it when there is nothing to report. */
249
+ function updateDrift(vm: DriftNoticeVm | null): void {
250
+ driftKey = vm?.key ?? null;
251
+ // Dismissing hides the section the Dismiss button lives in, so read focus
252
+ // BEFORE hiding it: a keyboard operator who clicked Dismiss would otherwise
253
+ // be dropped at the top of the document. The status readout is the same
254
+ // anchor the confirm strip falls back to.
255
+ const focusWasInside = drift.contains(document.activeElement);
256
+ setAttr(drift, "hidden", vm === null);
257
+ if (vm === null) {
258
+ if (focusWasInside) status.focus();
259
+ return;
260
+ }
261
+ setAttr(drift, "aria-label", vm.ariaLabel);
262
+ setText(driftTitle, vm.title);
263
+ setText(driftDismiss, vm.dismissLabel);
264
+ setAttr(driftDismiss, "aria-label", vm.dismissAriaLabel);
265
+ syncRows(driftList, driftRows, vm.messages.length, () => ({
266
+ root: el("li", { class: "service__drift-item" }),
267
+ }));
268
+ vm.messages.forEach((message, index) => {
269
+ const row = driftRows[index];
270
+ if (row !== undefined) setText(row.root, message);
271
+ });
272
+ setText(driftFix, vm.fix);
273
+ }
274
+
275
+ return {
276
+ el: root,
277
+ update(vm) {
278
+ // The state carries in the dot's SHAPE (the stylesheet keys three forms
279
+ // off this attribute) and in the word. The hue only tints the dot: the
280
+ // word is painted with a text token so it clears AA in both themes.
281
+ setAttr(status, "data-state", vm.state);
282
+ setVar(statusDot, "fill", vm.statusDotColor);
283
+ setText(statusLabel, vm.statusLabel);
284
+ setAttr(status, "aria-label", `Service ${vm.statusLabel}`);
285
+
286
+ setText(theme, vm.themeGlyph);
287
+ setAttr(theme, "aria-label", vm.themeLabel);
288
+ setAttr(theme, "title", vm.themeLabel);
289
+
290
+ updateDrift(vm.drift);
291
+
292
+ const { controls: cvm } = vm;
293
+ setAttr(controls, "hidden", cvm.buttons.length === 0);
294
+ // The row is busy as a whole while a command is out: its buttons are
295
+ // disabled, and assistive tech is told why rather than finding them inert.
296
+ setAttr(controls, "aria-busy", cvm.pending ? "true" : "false");
297
+ syncRows(controls, controlRows, cvm.buttons.length, createControlRow);
298
+ cvm.buttons.forEach((button: ServiceControlVm, index) => {
299
+ const row = controlRows[index];
300
+ if (row === undefined) return;
301
+ row.action = button.action;
302
+ row.confirms = button.confirms;
303
+ setText(row.root, button.label);
304
+ setAttr(row.root, "disabled", button.disabled);
305
+ setAttr(row.root, "aria-label", button.ariaLabel);
306
+ setAttr(row.root, "title", button.disabledReason === "" ? false : button.disabledReason);
307
+ setAttr(row.root, "data-tone", button.danger ? "danger" : "neutral");
308
+ });
309
+
310
+ setAttr(setup, "hidden", cvm.setup === null);
311
+ if (cvm.setup !== null) {
312
+ setText(setupLabel, cvm.setup.label);
313
+ setText(setupDetail, cvm.setup.detail);
314
+ setText(setupCommand, cvm.setup.command);
315
+ }
316
+
317
+ const wasConfirming = confirmAction;
318
+ // Read before the strip is hidden: hiding an ancestor drops the focus.
319
+ const focusWasInStrip = confirm.contains(document.activeElement);
320
+ confirmAction = cvm.confirm?.action ?? null;
321
+ setAttr(confirm, "hidden", cvm.confirm === null);
322
+ if (cvm.confirm !== null) {
323
+ setText(confirmText, cvm.confirm.consequence);
324
+ setText(confirmCancel, cvm.confirm.cancelLabel);
325
+ setText(confirmAccept, cvm.confirm.confirmLabel);
326
+ setAttr(confirmAccept, "aria-label", cvm.confirm.confirmAriaLabel);
327
+ // Opening moves focus to the safe choice, so a keyboard operator lands
328
+ // on Cancel and has to travel to confirm — never the other way round.
329
+ if (wasConfirming === null) confirmCancel.focus();
330
+ }
331
+ // Closing is where focus is most easily lost: accepting disables every
332
+ // button while the command is out, so the opener is usually inert by the
333
+ // time we get here. Resolve it after the notice is patched, and land on
334
+ // the outcome — the alert if there is one, else the status readout —
335
+ // rather than dropping a keyboard operator at the top of the document.
336
+ const restoreFrom = cvm.confirm === null && wasConfirming !== null && focusWasInStrip;
337
+
338
+ setAttr(notice, "hidden", cvm.notice === null);
339
+ if (cvm.notice !== null) setText(notice, cvm.notice);
340
+
341
+ if (restoreFrom) {
342
+ const opener = controlRows.find((row) => row.action === wasConfirming);
343
+ if (opener !== undefined && !opener.root.disabled) opener.root.focus();
344
+ else if (cvm.notice !== null) notice.focus();
345
+ else status.focus();
346
+ }
347
+
348
+ syncRows(facts, rows, vm.config.length, createFactRow);
349
+ vm.config.forEach((entry, index) => {
350
+ const row = rows[index];
351
+ if (row === undefined) return;
352
+ // `label: value`, the same grammar the model-card fields use.
353
+ setText(row.key, `${entry.key}:`);
354
+ setText(row.value, entry.value);
355
+ });
356
+ },
357
+ };
358
+ }