dsh-workflow-map 0.0.0-stage → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Settings.js — presentation preferences and the in-panel settings card.
3
+ *
4
+ * Everything here is display-only. A setting never reaches the host half, never
5
+ * changes a snapshot, and never writes anything except the one localStorage record
6
+ * that store.js already owns (`dsh-workflow-map:prefs`).
7
+ *
8
+ * The allow-lists live in theme.js (WF_PALETTES / WF_MOTIONS / WF_DETAIL_MODES /
9
+ * WF_NODE_LIMITS), so the sanitizer, the segmented control and the CSS cannot drift
10
+ * apart. A corrupted / older record degrades field by field to the documented
11
+ * defaults instead of blanking the panel.
12
+ *
13
+ * Pure: `h` and `say` are injected. Inlined into client.js by tools/build-client.mjs.
14
+ */
15
+
16
+ /** The documented defaults ("Reset to defaults" restores exactly these values). */
17
+ function wfDefaultSettings() {
18
+ return {
19
+ palette: "rainbow",
20
+ motion: "full",
21
+ detail: "summary",
22
+ nodeLimit: 60,
23
+ };
24
+ }
25
+
26
+ function wfAllowed(value, allowed, fallback) {
27
+ if (typeof value === "string") {
28
+ for (var i = 0; i < allowed.length; i += 1) if (value === allowed[i]) return value;
29
+ }
30
+ for (var f = 0; f < allowed.length; f += 1) if (fallback === allowed[f]) return fallback;
31
+ return allowed[0];
32
+ }
33
+
34
+ /**
35
+ * 60 / 30 / 120 / 0 - 0 means "all". The string "all" is accepted on read so a
36
+ * hand-edited record cannot strand the panel, but it is persisted as 0.
37
+ */
38
+ function wfSanitizeNodeLimit(value, fallback) {
39
+ var fallbackNumber = wfFinite(fallback) === null ? 60 : Math.round(fallback);
40
+ if (WF_NODE_LIMITS.indexOf(fallbackNumber) === -1) fallbackNumber = 60;
41
+ if (typeof value === "string" && value.toLowerCase() === "all") return 0;
42
+ var number = wfFinite(value);
43
+ if (number === null) return fallbackNumber;
44
+ number = Math.round(number);
45
+ return WF_NODE_LIMITS.indexOf(number) === -1 ? fallbackNumber : number;
46
+ }
47
+
48
+ /**
49
+ * Sanitize the four presentation fields. `fallback` may be the full prefs record
50
+ * (store.js passes it), so a partial or stale record keeps the fields it still has.
51
+ */
52
+ function wfSanitizeSettings(input, fallback) {
53
+ var base = fallback && typeof fallback === "object" ? fallback : wfDefaultSettings();
54
+ var defaults = wfDefaultSettings();
55
+ return {
56
+ palette: wfAllowed(input && input.palette, WF_PALETTES, wfAllowed(base.palette, WF_PALETTES, defaults.palette)),
57
+ motion: wfAllowed(input && input.motion, WF_MOTIONS, wfAllowed(base.motion, WF_MOTIONS, defaults.motion)),
58
+ detail: wfAllowed(input && input.detail, WF_DETAIL_MODES, wfAllowed(base.detail, WF_DETAIL_MODES, defaults.detail)),
59
+ nodeLimit: wfSanitizeNodeLimit(input && input.nodeLimit, wfSanitizeNodeLimit(base.nodeLimit, defaults.nodeLimit)),
60
+ };
61
+ }
62
+
63
+ /** The compact copy the model carries to the renderers. */
64
+ function wfSettingsOf(prefs) {
65
+ return wfSanitizeSettings(prefs, wfDefaultSettings());
66
+ }
67
+
68
+ /** Localised label for one option; values are code, labels are dictionary entries. */
69
+ function wfSettingOptionKey(setting, value) {
70
+ if (setting === "palette") return "panel.settings.palette." + value;
71
+ if (setting === "motion") return "panel.settings.motion." + value;
72
+ if (setting === "detail") return "panel.settings.detail." + value;
73
+ if (setting === "nodeLimit") return value === 0 ? "panel.settings.limit.all" : null;
74
+ return null;
75
+ }
76
+
77
+ function wfSettingOptionLabel(setting, value, say) {
78
+ var key = wfSettingOptionKey(setting, value);
79
+ if (key !== null) return say(key);
80
+ return String(value);
81
+ }
82
+
83
+ /** One segmented row. Buttons are the visible state (`aria-pressed`), not a select. */
84
+ function wfRenderSettingSegment(h, setting, labelKey, values, current, say, actions) {
85
+ var fire = actions || {};
86
+ var buttons = values.map(function (value) {
87
+ var active = value === current;
88
+ var label = wfSettingOptionLabel(setting, value, say);
89
+ return h(
90
+ "button",
91
+ {
92
+ type: "button",
93
+ key: setting + "-" + String(value),
94
+ className: "wf-seg-btn",
95
+ "data-setting": setting,
96
+ "data-value": String(value),
97
+ "data-dsh-workflow-map": "setting-option",
98
+ "aria-pressed": active ? "true" : "false",
99
+ title: say(labelKey) + " · " + label,
100
+ onClick: function () {
101
+ if (typeof fire.onSetting === "function") fire.onSetting(setting, value);
102
+ },
103
+ },
104
+ label
105
+ );
106
+ });
107
+ return h(
108
+ "div",
109
+ { className: "wf-set-grid", key: "row-" + setting, "data-setting-row": setting },
110
+ h("span", { className: "wf-set-label", key: "label" }, say(labelKey)),
111
+ h(
112
+ "div",
113
+ { className: "wf-seg", key: "seg", role: "group", "aria-label": say(labelKey) },
114
+ buttons
115
+ )
116
+ );
117
+ }
118
+
119
+ /** A copy button: the 1.5 s acknowledgement is repainted by NodeDetail's helper. */
120
+ function wfRenderExportCopy(h, kind, labelKey, say, actions) {
121
+ var fire = actions || {};
122
+ return h(
123
+ "button",
124
+ {
125
+ type: "button",
126
+ key: "copy-" + kind,
127
+ className: "wf-link",
128
+ "data-export": "copy-" + kind,
129
+ "data-wf-copy-label": say(labelKey),
130
+ onClick: function (event) {
131
+ var element = event && event.currentTarget ? event.currentTarget : null;
132
+ if (typeof fire.onExportCopy === "function") fire.onExportCopy(kind, element);
133
+ },
134
+ },
135
+ h("span", { className: "wf-copy-label", key: "label" }, say(labelKey)),
136
+ h("span", { className: "wf-copy-status", key: "status", "aria-live": "polite" }, "")
137
+ );
138
+ }
139
+
140
+ function wfRenderExportDownload(h, kind, labelKey, say, actions) {
141
+ var fire = actions || {};
142
+ return h(
143
+ "button",
144
+ {
145
+ type: "button",
146
+ key: "download-" + kind,
147
+ className: "wf-link",
148
+ "data-export": "download-" + kind,
149
+ title: say(labelKey),
150
+ onClick: function () {
151
+ if (typeof fire.onExportDownload === "function") fire.onExportDownload(kind);
152
+ },
153
+ },
154
+ say(labelKey)
155
+ );
156
+ }
157
+
158
+ /**
159
+ * The whole in-panel settings section. It replaces the diagnostic strip:
160
+ * the endpoint / counts / rev remain at the bottom, but the top is now the actual
161
+ * four preferences plus the two export pairs.
162
+ */
163
+ function wfRenderSettings(h, prefs, say, actions, extras) {
164
+ var fire = actions || {};
165
+ var settings = wfSanitizeSettings(prefs, wfDefaultSettings());
166
+ var ui = extras || {};
167
+ return h(
168
+ "div",
169
+ { className: "wf-settings", key: "settings", "data-dsh-workflow-map": "settings" },
170
+ wfRenderSettingSegment(h, "palette", "panel.settings.palette", WF_PALETTES, settings.palette, say, fire),
171
+ wfRenderSettingSegment(h, "motion", "panel.settings.motion", WF_MOTIONS, settings.motion, say, fire),
172
+ wfRenderSettingSegment(h, "detail", "panel.settings.detail", WF_DETAIL_MODES, settings.detail, say, fire),
173
+ wfRenderSettingSegment(h, "nodeLimit", "panel.settings.limit", WF_NODE_LIMITS, settings.nodeLimit, say, fire),
174
+ h(
175
+ "div",
176
+ { className: "wf-set-actions", key: "actions" },
177
+ h(
178
+ "button",
179
+ {
180
+ type: "button",
181
+ key: "reset",
182
+ className: "wf-link",
183
+ "data-action": "reset-prefs",
184
+ title: say("panel.settings.reset.hint"),
185
+ onClick: function () {
186
+ if (typeof fire.onReset === "function") fire.onReset();
187
+ },
188
+ },
189
+ say("panel.settings.reset")
190
+ ),
191
+ h("span", { className: "wf-kv", key: "counts" }, say("panel.settings.counts", {
192
+ steps: ui.steps === undefined ? 0 : ui.steps,
193
+ nodes: ui.nodes === undefined ? 0 : ui.nodes,
194
+ })),
195
+ wfFinite(ui.rev) === null ? null : h("span", { className: "wf-kv", key: "rev" }, say("panel.settings.rev", { rev: ui.rev }))
196
+ ),
197
+ h(
198
+ "div",
199
+ { className: "wf-export", key: "export" },
200
+ h("span", { className: "wf-export-label", key: "label" }, say("panel.settings.export")),
201
+ wfRenderExportCopy(h, "md", "panel.settings.export.markdown", say, fire),
202
+ wfRenderExportCopy(h, "json", "panel.settings.export.json", say, fire),
203
+ wfRenderExportDownload(h, "md", "panel.settings.export.downloadmarkdown", say, fire),
204
+ wfRenderExportDownload(h, "json", "panel.settings.export.downloadjson", say, fire)
205
+ ),
206
+ h(
207
+ "div",
208
+ { className: "wf-set-actions", key: "diag" },
209
+ h("span", { className: "wf-kv", key: "ep" }, say("panel.settings.endpoint") + " " + String(ui.endpoint || ""))
210
+ )
211
+ );
212
+ }
@@ -0,0 +1,479 @@
1
+ /**
2
+ * StepColumn.js — the left column: the task's step chain, one card per step.
3
+ *
4
+ * Each `<li>` is a CARD plus the progress rail that hangs under its frame
5
+ * (ProgressUnder.js) plus — except on the last card — a 14 px connector line.
6
+ * The rail is deliberately OUTSIDE the card's border box with WF_STEP_GAP of
7
+ * air, so the card still reads as a card and the bar as an annotation of it.
8
+ *
9
+ * STATUS POLICY (the one rule this file exists for):
10
+ * PAE `plan.statuses` > the todo table > `plan.anchorSeqs` inference >
11
+ * the host's event-derived status > "pending".
12
+ *
13
+ * Why the browser re-derives what the host already merged: the host may see a
14
+ * PAE-opened row's event status before PAE rewrites `statuses`, and the plan file
15
+ * is the published truth ("completed" must render as completed). `anchorSeqs`
16
+ * covers the window where a step has been opened but no status/report landed yet:
17
+ * a step with an anchor is never "not started".
18
+ *
19
+ * Pure: no React import, no DOM. `h` and `say` are injected so
20
+ * tools/selftest-panel.mjs can drive the exact tree the frame will mount.
21
+ *
22
+ * Inlined into client.js by tools/build-client.mjs (single-file browser half).
23
+ */
24
+
25
+ /** PAE / todo status vocabulary → this plugin's five card states. A Map, so a
26
+ * crafted "constructor" key cannot resolve through Object.prototype. */
27
+ var WF_STATUS_ALIASES = new Map([
28
+ ["completed", "done"],
29
+ ["complete", "done"],
30
+ ["done", "done"],
31
+ ["success", "done"],
32
+ ["succeeded", "done"],
33
+ ["finished", "done"],
34
+ ["in_progress", "running"],
35
+ ["in-progress", "running"],
36
+ ["running", "running"],
37
+ ["executing", "running"],
38
+ ["processing", "running"],
39
+ ["active", "running"],
40
+ ["pending", "pending"],
41
+ ["queued", "pending"],
42
+ ["todo", "pending"],
43
+ ["not_started", "pending"],
44
+ ["not-started", "pending"],
45
+ ["idle", "pending"],
46
+ ["failed", "failed"],
47
+ ["failure", "failed"],
48
+ ["error", "failed"],
49
+ ["errored", "failed"],
50
+ ["aborted", "failed"],
51
+ ["skipped", "skipped"],
52
+ ["skip", "skipped"],
53
+ ["cancelled", "skipped"],
54
+ ["canceled", "skipped"],
55
+ ]);
56
+
57
+ /** The ring glyph per state: ✓ done, ● running, ✕ failed, ○ not started. */
58
+ var WF_STATUS_GLYPH = {
59
+ done: "✓",
60
+ running: "●",
61
+ failed: "✕",
62
+ pending: "○",
63
+ skipped: "–",
64
+ };
65
+
66
+ var WF_STATUS_SAY = {
67
+ done: "panel.status.done",
68
+ running: "panel.status.running",
69
+ failed: "panel.status.failed",
70
+ pending: "panel.status.pending",
71
+ skipped: "panel.status.skipped",
72
+ };
73
+
74
+ /** A status label from any of the three sources → canonical state, or null. */
75
+ function wfCanonicalStatus(label) {
76
+ if (typeof label !== "string") return null;
77
+ var key = label.trim().toLowerCase();
78
+ if (key === "") return null;
79
+ return WF_STATUS_ALIASES.get(key) || null;
80
+ }
81
+
82
+ function wfStatusSayKey(status) {
83
+ return WF_STATUS_SAY[status] || WF_STATUS_SAY.pending;
84
+ }
85
+
86
+ function wfStatusGlyph(status) {
87
+ return WF_STATUS_GLYPH[status] || WF_STATUS_GLYPH.pending;
88
+ }
89
+
90
+ /* ------------------------------------------------------------------------- */
91
+ /* matching a step to its todo row */
92
+ /* ------------------------------------------------------------------------- */
93
+
94
+ /** "5. install into the desktop profile..." -> "install into the desktop profile..." (the ordinal is not part
95
+ * of the name). Also strips a trailing run of list punctuation. */
96
+ function wfStripStepOrdinal(text) {
97
+ if (typeof text !== "string") return "";
98
+ return text.replace(/^\s*\d+\s*[.、).::]\s*/, "");
99
+ }
100
+
101
+ function wfNormalizeStepTitle(text) {
102
+ if (typeof text !== "string") return "";
103
+ return wfStripStepOrdinal(text)
104
+ .replace(/\s+/g, " ")
105
+ .replace(/[,。,.、;;::!?!?]+$/g, "")
106
+ .trim()
107
+ .toLowerCase();
108
+ }
109
+
110
+ /** The ordinal a todo row carries ("5. …" → 5), or null. */
111
+ function wfTodoOrdinal(content) {
112
+ if (typeof content !== "string") return null;
113
+ var match = /^\s*(\d+)\s*[.、).::]/.exec(content);
114
+ if (!match) return null;
115
+ var value = Number(match[1]);
116
+ return isFinite(value) ? value : null;
117
+ }
118
+
119
+ /**
120
+ * Index the todo table both ways, because the two ways fail differently:
121
+ * an ordinal can be stale after a plan edit, and a title can be re-worded.
122
+ * First row wins (`Map` insertion order), matching the fold's "first match" rule.
123
+ */
124
+ function wfBuildTodoIndex(todos) {
125
+ var byOrdinal = new Map();
126
+ var byTitle = new Map();
127
+ if (!Array.isArray(todos)) return { byOrdinal: byOrdinal, byTitle: byTitle };
128
+ for (var i = 0; i < todos.length; i += 1) {
129
+ var todo = todos[i];
130
+ if (!todo || typeof todo !== "object") continue;
131
+ var content = typeof todo.content === "string" ? todo.content : "";
132
+ var status = typeof todo.status === "string" ? todo.status : "";
133
+ if (content === "" || status === "") continue;
134
+ var ordinal = wfTodoOrdinal(content);
135
+ if (ordinal !== null && !byOrdinal.has(ordinal)) byOrdinal.set(ordinal, status);
136
+ var title = wfNormalizeStepTitle(content);
137
+ if (title !== "" && !byTitle.has(title)) byTitle.set(title, status);
138
+ }
139
+ return { byOrdinal: byOrdinal, byTitle: byTitle };
140
+ }
141
+
142
+ /* ------------------------------------------------------------------------- */
143
+ /* status derivation */
144
+ /* ------------------------------------------------------------------------- */
145
+
146
+ function wfHasKey(object, key) {
147
+ return !!object && Object.prototype.hasOwnProperty.call(object, key);
148
+ }
149
+
150
+ /**
151
+ * The effective status of one row and WHERE it came from (so the card can say
152
+ * `data-status-source` and the test can prove the priority actually held).
153
+ *
154
+ * @returns {{status:string, source:"pae"|"todo"|"anchor"|"host"|"none"}}
155
+ */
156
+ function wfDeriveStepStatus(row, plan, todoIndex, currentPlanIndex) {
157
+ var planIndex = row && wfFinite(row.planIndex) !== null ? Math.round(row.planIndex) : null;
158
+
159
+ if (plan && planIndex !== null) {
160
+ // 1. PAE's own statuses are the published truth.
161
+ var label = wfHasKey(plan.statuses, String(planIndex)) ? plan.statuses[String(planIndex)] : null;
162
+ var fromPae = wfCanonicalStatus(label);
163
+ var skipped = Array.isArray(plan.skipped) && plan.skipped.indexOf(planIndex) >= 0;
164
+ if (skipped && (fromPae === null || fromPae === "pending")) return { status: "skipped", source: "pae" };
165
+ if (fromPae !== null) return { status: fromPae, source: "pae" };
166
+
167
+ // 2. The todo table (what the model wrote into its own checklist).
168
+ var fromTodo = null;
169
+ if (todoIndex) {
170
+ if (todoIndex.byOrdinal.has(planIndex)) fromTodo = wfCanonicalStatus(todoIndex.byOrdinal.get(planIndex));
171
+ if (fromTodo === null) {
172
+ var title = wfNormalizeStepTitle(row.title);
173
+ if (title !== "" && todoIndex.byTitle.has(title)) fromTodo = wfCanonicalStatus(todoIndex.byTitle.get(title));
174
+ }
175
+ }
176
+ if (fromTodo !== null) return { status: fromTodo, source: "todo" };
177
+
178
+ // 3. An anchored step has been opened. The plan's own cursor decides whether
179
+ // it is behind us (done), here (running) or still ahead (pending).
180
+ if (wfHasKey(plan.anchorSeqs, String(planIndex))) {
181
+ if (currentPlanIndex !== null && planIndex < currentPlanIndex) return { status: "done", source: "anchor" };
182
+ if (currentPlanIndex !== null && planIndex === currentPlanIndex) return { status: "running", source: "anchor" };
183
+ if (currentPlanIndex !== null && planIndex > currentPlanIndex) return { status: "pending", source: "anchor" };
184
+ return { status: "running", source: "anchor" };
185
+ }
186
+ } else if (todoIndex) {
187
+ // A row with no plan ordinal (planning-phase work) can still be in the todo
188
+ // table; match it by name only.
189
+ var fallbackTitle = wfNormalizeStepTitle(row && row.title);
190
+ if (fallbackTitle !== "" && todoIndex.byTitle.has(fallbackTitle)) {
191
+ var named = wfCanonicalStatus(todoIndex.byTitle.get(fallbackTitle));
192
+ if (named !== null) return { status: named, source: "todo" };
193
+ }
194
+ }
195
+
196
+ // 4. The host's event-derived status, then the honest floor.
197
+ var fromHost = wfCanonicalStatus(row && row.status);
198
+ if (fromHost !== null) return { status: fromHost, source: "host" };
199
+ return { status: "pending", source: "none" };
200
+ }
201
+
202
+ /**
203
+ * Rewrite the model's rows with the derived status. Called by wfBuildModel (store.js)
204
+ * so the header counts, the selection fallback and every card agree on one truth.
205
+ */
206
+ function wfApplyDerivedStatuses(rows, plan, todos) {
207
+ var todoIndex = wfBuildTodoIndex(todos);
208
+ var current = plan && wfFinite(plan.stepIndex) !== null ? Math.round(plan.stepIndex) : null;
209
+ var out = [];
210
+ for (var i = 0; i < rows.length; i += 1) {
211
+ var row = rows[i];
212
+ var derived = wfDeriveStepStatus(row, plan, todoIndex, current);
213
+ var requiresConfirmation = false;
214
+ if (plan && plan.confirmations && wfFinite(row.planIndex) !== null) {
215
+ requiresConfirmation = plan.confirmations[String(Math.round(row.planIndex))] === true;
216
+ }
217
+ out.push({
218
+ index: row.index,
219
+ title: row.title,
220
+ status: derived.status,
221
+ statusKey: wfStatusSayKey(derived.status),
222
+ statusSource: derived.source,
223
+ rawStatus: row.status,
224
+ percent: row.percent,
225
+ percentSource: row.percentSource,
226
+ // carried through the status rewrite so every row keeps the stamp the host
227
+ // shipped, whichever policy decided its status.
228
+ lastEstimateAt: wfFinite(row.lastEstimateAt) === null ? 0 : Math.max(0, Math.round(wfFinite(row.lastEstimateAt))),
229
+ nodeCount: row.nodeCount,
230
+ planIndex: row.planIndex,
231
+ anchorSeq: row.anchorSeq,
232
+ requiresConfirmation: requiresConfirmation,
233
+ // carried through the status rewrite: the rows the host folded for this step (an
234
+ // open approval, an unanswered question). Bounded to the same limit the host applies, so a
235
+ // damaged record cannot flood the badge row.
236
+ pendingConfirm: Array.isArray(row.pendingConfirm) ? row.pendingConfirm.slice(0, 8) : [],
237
+ reportSummary: row.reportSummary,
238
+ // the step detail needs the clock and the report; both are carried here so
239
+ // every row has the same shape whether or not it is the selected one.
240
+ startedAt: wfFinite(row.startedAt),
241
+ endedAt: wfFinite(row.endedAt),
242
+ outcome: typeof row.outcome === "string" ? row.outcome : "",
243
+ report: wfStepReport(plan, row.planIndex),
244
+ active: row.active,
245
+ selected: false,
246
+ });
247
+ }
248
+ return out;
249
+ }
250
+
251
+ /* ------------------------------------------------------------------------- */
252
+ /* rendering */
253
+ /* ------------------------------------------------------------------------- */
254
+
255
+ function wfPad2(value) {
256
+ var text = String(value);
257
+ return text.length >= 2 ? text : "0" + text;
258
+ }
259
+
260
+ function wfStepFire(fn, argument) {
261
+ if (typeof fn === "function") fn(argument);
262
+ }
263
+
264
+ /** The disclosure glyph. Pure, so a self-test can prove it follows the state. */
265
+ function wfTriGlyph(expanded) {
266
+ return expanded === true ? "▾" : "▸";
267
+ }
268
+
269
+ /**
270
+ * The ONE disclosure triangle , shared by a step card and a folded run.
271
+ *
272
+ * It is a real <button>, not a ::before glyph: a pseudo-element is not a hit
273
+ * target, cannot be Tab-reached and cannot carry aria-expanded. It is also a
274
+ * SIBLING of the row it discloses — a button inside a button is invalid HTML and
275
+ * unreachable by keyboard — dropped into the SAME grid cell as that row, so the
276
+ * whole 20 px band down the row's height is clickable and the glyph sits in the
277
+ * band the row reserves with padding-left.
278
+ *
279
+ * `extra` carries what only the caller knows:
280
+ * {label, controls, data, key} the aria-label/title, aria-controls target,
281
+ * the data-dsh-workflow-map tag and the React key.
282
+ */
283
+ function wfTri(h, expanded, onToggle, extra) {
284
+ var open = expanded === true;
285
+ var fire = typeof onToggle === "function" ? onToggle : null;
286
+ var info = extra && typeof extra === "object" ? extra : {};
287
+ var label = typeof info.label === "string" ? info.label : "";
288
+ return h(
289
+ "button",
290
+ {
291
+ type: "button",
292
+ className: "wf-tri",
293
+ key: typeof info.key === "string" ? info.key : "tri",
294
+ "data-dsh-workflow-map": typeof info.data === "string" ? info.data : "tri",
295
+ "data-expanded": open ? "true" : "false",
296
+ "aria-expanded": open ? "true" : "false",
297
+ "aria-controls": typeof info.controls === "string" ? info.controls : undefined,
298
+ title: label,
299
+ "aria-label": label,
300
+ onClick: function () {
301
+ if (fire !== null) fire();
302
+ },
303
+ },
304
+ wfTriGlyph(open)
305
+ );
306
+ }
307
+
308
+ /**
309
+ * One `<li>`: card (+ its 「i」) + rail + the step detail + (unless last) the
310
+ * 14 px connector to the next card. `info` carries {detail, open} for THIS row only.
311
+ */
312
+ function wfRenderStepItem(h, row, position, total, say, actions, info) {
313
+ var status = row.status;
314
+ var fire = actions || {};
315
+ var glyph = wfStatusGlyph(status);
316
+ var detail = info && info.detail && info.detail.index === row.index ? info.detail : null;
317
+ // `asOf` is the ONE moment every timing segment is measured against. The host
318
+ // ships it in the view; passing it down keeps the renderer clock-free and the numbers
319
+ // reproducible in a self-test with a fixed stamp.
320
+ var compact = !!(info && info.compact === true);
321
+ var open = detail !== null && info.open === true && !compact;
322
+
323
+ var card = h(
324
+ "button",
325
+ {
326
+ type: "button",
327
+ className: "wf-step-card",
328
+ key: "card",
329
+ "data-dsh-workflow-map": "step",
330
+ "data-step-index": row.index,
331
+ "data-plan-index": row.planIndex === null ? undefined : row.planIndex,
332
+ "data-status": status,
333
+ "data-status-source": row.statusSource,
334
+ "data-selected": row.selected ? "true" : "false",
335
+ "data-active": row.active ? "true" : "false",
336
+ "aria-pressed": row.selected ? "true" : "false",
337
+ title: row.title,
338
+ onClick: function () {
339
+ wfStepFire(fire.onSelectStep, row.index);
340
+ },
341
+ },
342
+ h("span", { className: "wf-step-ring", key: "ring", "aria-hidden": "true" }, glyph),
343
+ h(
344
+ "span",
345
+ { className: "wf-step-main", key: "main" },
346
+ h("span", { className: "wf-step-title", key: "title" }, row.title),
347
+ row.reportSummary === "" || compact
348
+ ? null
349
+ : h("span", { className: "wf-step-sum", key: "sum", title: row.reportSummary }, row.reportSummary),
350
+ h(
351
+ "span",
352
+ { className: "wf-step-meta", key: "meta" },
353
+ h("span", { className: "wf-badge", key: "badge" }, say(wfStatusSayKey(status))),
354
+ row.requiresConfirmation
355
+ ? h(
356
+ "span",
357
+ { className: "wf-step-flag", key: "flag", title: say("panel.step.confirm") },
358
+ say("panel.step.confirm")
359
+ )
360
+ : null
361
+ ),
362
+ // "working ..." only on the step that is running right now: a 6 px breathing dot
363
+ // plus a three-dot ellipsis. It sits under the title line, so a running card never
364
+ // reflows the two-line clamp of `.wf-step-title`.
365
+ status === "running" ? wfRenderLive(h, say, "step") : null
366
+ ),
367
+ h("span", { className: "wf-step-seq", key: "seq" }, wfPad2(position + 1) + "/" + wfPad2(total))
368
+ );
369
+
370
+ return h(
371
+ "li",
372
+ {
373
+ className: "wf-step-item",
374
+ key: "step-" + row.index,
375
+ "data-step-index": row.index,
376
+ "data-status": status,
377
+ "data-selected": row.selected ? "true" : "false",
378
+ "data-detail": compact ? "title" : "summary",
379
+ },
380
+ // the disclosure triangle is a real <button>, a SIBLING of the card (a
381
+ // button inside a button is invalid HTML and unreachable by keyboard). The <li> is a
382
+ // one-column grid, so the triangle and the card share cell 1/1: the triangle spans the
383
+ // card's full height in the 20 px band the card reserves with padding-left, and the
384
+ // card stays a DIRECT child (the running-frame ring and FlowEffects query it that way).
385
+ compact
386
+ ? null
387
+ : wfTri(h, open, function () {
388
+ wfStepFire(fire.onStepInfo, row.index);
389
+ }, {
390
+ key: "tri",
391
+ data: "step-tri",
392
+ label: say(open ? "panel.step.collapse" : "panel.step.expand"),
393
+ controls: "wf-step-detail-" + row.index,
394
+ }),
395
+ card,
396
+ // The 「i」 is a SIBLING of the card too (same reason as the triangle): it is absolutely
397
+ // positioned inside the 26 px right band the card reserves, so the title never runs
398
+ // underneath it. keeps it — two entry points, one state source.
399
+ compact ? null : h(
400
+ "button",
401
+ {
402
+ type: "button",
403
+ className: "wf-step-info",
404
+ key: "info",
405
+ "data-dsh-workflow-map": "step-info",
406
+ "data-step-index": row.index,
407
+ "data-open": open ? "true" : "false",
408
+ "aria-expanded": open ? "true" : "false",
409
+ "aria-controls": "wf-step-detail-" + row.index,
410
+ title: say(open ? "panel.step.detailClose" : "panel.step.detailOpen"),
411
+ "aria-label": say(open ? "panel.step.detailClose" : "panel.step.detailOpen"),
412
+ onClick: function () {
413
+ wfStepFire(fire.onStepInfo, row.index);
414
+ },
415
+ },
416
+ "i"
417
+ ),
418
+ // the rail carries the number, its age and its projection. `compact` is passed on
419
+ // the row because the dock renders the two-line layout only when there is room for it.
420
+ wfRenderProgressDock(h, compact ? { ...row, compact: true } : row, say, info && info.asOf),
421
+ open ? wfRenderStepDetail(h, detail, say, actions) : null,
422
+ position + 1 < total
423
+ ? h("span", {
424
+ className: "wf-step-link",
425
+ key: "link",
426
+ "aria-hidden": "true",
427
+ "data-done": status === "done" ? "true" : "false",
428
+ })
429
+ : null
430
+ );
431
+ }
432
+
433
+ /**
434
+ * The whole left column: a sticky header inside an internally scrolling list, so
435
+ * a 15-step plan keeps its "total steps · 5/15" caption while the cards move.
436
+ */
437
+ function wfRenderStepColumn(h, model, say, actions, ui) {
438
+ var total = model.steps.length;
439
+ // `ui` is the closed step index (number) or null; the detail itself is built by store.js
440
+ // for the selected step only, so a stale index can never render another step's report.
441
+ var detail = model.detail && model.detail.index === model.selected ? model.detail : null;
442
+ var header = h(
443
+ "div",
444
+ { className: "wf-col-h wf-col-h-sticky", key: "h" },
445
+ h("span", { key: "label" }, say("panel.steps")),
446
+ h("span", { className: "wf-count", key: "count" }, model.progress.done + "/" + model.progress.total),
447
+ wfRenderStepHead(h, detail, say)
448
+ );
449
+
450
+ return h(
451
+ "div",
452
+ {
453
+ className: "wf-col wf-col-steps",
454
+ key: "left",
455
+ "data-dsh-workflow-map": "steps",
456
+ "data-many": total > WF_STEP_MANY ? "true" : "false",
457
+ },
458
+ h(
459
+ "div",
460
+ { className: "wf-scroll wf-steps-scroll", key: "list" },
461
+ header,
462
+ h(
463
+ "ul",
464
+ { className: "wf-steps", key: "steps", role: "list" },
465
+ model.steps.map(function (row, position) {
466
+ return wfRenderStepItem(h, row, position, total, say, actions, {
467
+ detail: detail,
468
+ open: detail !== null && !(model.settings && model.settings.detail === "title") && wfStepDetailOpen(ui, model.selected),
469
+ compact: !!(model.settings && model.settings.detail === "title"),
470
+ // the shared "now" for every rail's age arithmetic. `view.asOf` is the
471
+ // host's own stamp (the ONE honest clock in the plugin); `model.now` is the render
472
+ // moment the panel passed in and is only a fallback for a view that carries none.
473
+ asOf: model.asOf === null || model.asOf === undefined ? model.now : model.asOf,
474
+ });
475
+ })
476
+ )
477
+ )
478
+ );
479
+ }