pandorga 1.4.5 → 1.4.7

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c08f73c874e192ee0b1bede450515781124405c6875afec2ac4f6500e3a1db46
4
- data.tar.gz: 55c89932abd93336991692769b088ca9dc798d04c2f7615cd175f67f156f06d4
3
+ metadata.gz: b72923b7c78519c2e27d696f963cf147445dd0d8dd089f90a69cde8c2247acbd
4
+ data.tar.gz: 703d26b69d010bcd5e535eee0dfd64da725887439cd68edb7cd8da827acb69d8
5
5
  SHA512:
6
- metadata.gz: '00088be394eb9440225561ca538fd7aef974688c9563d293627b5f3e7ee1f5058c80a6ffb70734d7eaec249a1b50ccf81df68a8dce557519730d666b5da1e3a3'
7
- data.tar.gz: a3a0687866fe28126982d3564595fa6e2193034a2bd0f2e288ccf2e19d180765d5a4e2ca3d8ae92604a8d1c4eb92cf81136076ca292077ad9e6e14d92fbceb3b
6
+ metadata.gz: a974cbf723b43a543a0af0a489c74c08bac2bddd4b77275baf5e74c106fde22aa69ea4e0085f0a1a71ba9f25514db61d0e6fc96dd9f03f749645e38e93dfd704
7
+ data.tar.gz: 164b159223d96b978e251f271fc8aea4b6535ad3397205efa88860dd1211b31ace3661b3425d6540b72874f1cd8d4348f8480ae305eccce115d45756dd0bfb5b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.7] — 2026-10-07
4
+
5
+ ### Fixed
6
+
7
+ - Studio keeps the editor focused while the content-pipeline chip is spinning.
8
+ Status polls patch the toolbar chip instead of rebuilding the shell.
9
+ - Home draft cards use 16:9 thumbnails and are 20% shorter on desktop and mobile.
10
+
11
+ ## [1.4.6] — 2026-10-07
12
+
13
+ ### Fixed
14
+
15
+ - Pipeline config resolution no longer swallows `_config.yml` read failures as
16
+ a silent “unconfigured”; returns a clear message when
17
+ `pandorga.content.workflow` is missing
18
+ - Studio chip surfaces pipeline-status API errors on boot, not only after Save
19
+
20
+ ### Added
21
+
22
+ - Gate `test-studio-pipeline-config.mjs` for `parseContentWorkflowFromConfig`
23
+
3
24
  ## [1.4.5] — 2026-10-07
4
25
 
5
26
  ### Changed
@@ -845,10 +845,9 @@ html[data-theme="light"] .home-hero-plate {
845
845
  border-color var(--motion-duration-fast) var(--motion-ease-default);
846
846
  }
847
847
 
848
- /* ~40% taller poster than the shared 16/9 plate so the dropped-sheet body
849
- keeps room for the excerpt (four-up columns were clipping copy to a sliver). */
848
+ /* Shared 16:9 plate. The previous 16/12.6 crop made these thumbs tall. */
850
849
  .home-draft-card .ledger-poster {
851
- aspect-ratio: 16 / 12.6;
850
+ aspect-ratio: 16 / 9;
852
851
  }
853
852
 
854
853
  /* A handful of fixed degrees per grid position — not a JS random() — so the
@@ -875,8 +874,9 @@ html[data-theme="light"] .home-hero-plate {
875
874
  }
876
875
 
877
876
  .home-draft-card .ledger-card-body {
878
- padding: var(--space-md);
879
- gap: var(--space-xs);
877
+ /* 20% shorter stack than the previous dropped-sheet body, both breakpoints. */
878
+ padding: calc(var(--space-md) * 0.8);
879
+ gap: calc(var(--space-xs) * 0.8);
880
880
  }
881
881
 
882
882
  /* Up from 1rem. The four dropped sheets are the only place on the home page
@@ -903,19 +903,29 @@ html[data-theme="light"] .home-hero-plate {
903
903
  `flex: 1` gives the paragraph a definite block size from the flex
904
904
  allocation, so line-clamp/overflow clip against that height instead of
905
905
  growing past the ellipsis (the old flex:1 + clamp:2 bug that left text
906
- after "…" and sliced mid-glyph at the card edge).
907
- min-height ~40% above the old six-line band so short posters still leave
908
- readable excerpt room. */
906
+ after "…" and sliced mid-glyph at the card edge). */
909
907
  flex: 1 1 auto;
910
- min-height: calc(var(--type-subtitle) * var(--leading-body) * 8.4);
908
+ /* With the 16:9 plate, this band keeps the whole card about 20% shorter
909
+ than the old 16/12.6 poster and 8.4-line excerpt, on narrow and wide grids. */
910
+ min-height: calc(var(--type-subtitle) * var(--leading-body) * 5.33);
911
911
  height: 0;
912
912
  display: -webkit-box;
913
- -webkit-line-clamp: 8;
914
- line-clamp: 8;
913
+ -webkit-line-clamp: 5;
914
+ line-clamp: 5;
915
915
  -webkit-box-orient: vertical;
916
916
  overflow: hidden;
917
917
  }
918
918
 
919
+ /* Four-up posters were the tall part of the old card. Give the excerpt a
920
+ little of that height back so the wide card is 20% shorter too, not more. */
921
+ @media (min-width: 1024px) {
922
+ .home-draft-card .ledger-card-copy {
923
+ min-height: calc(var(--type-subtitle) * var(--leading-body) * 6.05);
924
+ -webkit-line-clamp: 6;
925
+ line-clamp: 6;
926
+ }
927
+ }
928
+
919
929
  .home-draft-card .ledger-card-foot .ledger-cta {
920
930
  font-size: var(--type-cta);
921
931
  }
@@ -307,21 +307,40 @@ export async function resolveContentWorkflowConfig(env) {
307
307
 
308
308
  try {
309
309
  const file = await getFile(env, "_config.yml");
310
- if (file?.content) {
311
- const parsed = parseContentWorkflowFromConfig(file.content);
312
- if (parsed.workflow) {
313
- return {
314
- workflow: parsed.workflow,
315
- ref: parsed.workflowRef || envRef || "main",
316
- source: "config",
317
- };
318
- }
310
+ if (!file?.content) {
311
+ return {
312
+ workflow: null,
313
+ ref: envRef || "main",
314
+ source: null,
315
+ error: "config_missing",
316
+ message: "_config.yml was not found on main.",
317
+ };
318
+ }
319
+ const parsed = parseContentWorkflowFromConfig(file.content);
320
+ if (parsed.workflow) {
321
+ return {
322
+ workflow: parsed.workflow,
323
+ ref: parsed.workflowRef || envRef || "main",
324
+ source: "config",
325
+ };
319
326
  }
320
- } catch {
321
- /* fall through to unconfigured */
327
+ return {
328
+ workflow: null,
329
+ ref: envRef || "main",
330
+ source: null,
331
+ error: "workflow_unset",
332
+ message:
333
+ "Set pandorga.content.workflow in _config.yml (e.g. content-pipeline.yml).",
334
+ };
335
+ } catch (err) {
336
+ return {
337
+ workflow: null,
338
+ ref: envRef || "main",
339
+ source: null,
340
+ error: err.code || "config_read_failed",
341
+ message: String(err.message || err),
342
+ };
322
343
  }
323
-
324
- return { workflow: null, ref: envRef || "main", source: null };
325
344
  }
326
345
 
327
346
  /**
@@ -345,8 +364,11 @@ export async function getContentPipelineStatus(env, sha) {
345
364
  sha: commitSha,
346
365
  state: "unconfigured",
347
366
  run: null,
348
- error: null,
349
- source: null,
367
+ error: resolved.error || "workflow_unset",
368
+ source: resolved.source,
369
+ message:
370
+ resolved.message ||
371
+ "Set pandorga.content.workflow in _config.yml (e.g. content-pipeline.yml).",
350
372
  };
351
373
  }
352
374
  if (commitSha && !/^[0-9a-f]{7,40}$/.test(commitSha)) {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pandorga
4
- VERSION = "1.4.5"
4
+ VERSION = "1.4.7"
5
5
  end
@@ -32,7 +32,6 @@
32
32
  "article.home-project-specimen",
33
33
  "div.home-specimen-strip",
34
34
  "div.ledger-meta",
35
- "span.ledger-status",
36
35
  "div.home-specimen-figure",
37
36
  "div.ledger-poster",
38
37
  "img",
@@ -45,13 +44,16 @@
45
44
  "span.home-specimen-open"
46
45
  ],
47
46
  "forbidden": [
48
- "projects-plate"
47
+ "projects-plate",
48
+ "span.ledger-status"
49
+ ],
50
+ "excludes": [
51
+ ">Ongoing<"
49
52
  ],
50
53
  "contains": [
51
54
  ">#01<",
52
55
  ">Simulation<",
53
56
  ">2024<",
54
- ">Ongoing<",
55
57
  ">ARCO<",
56
58
  ">Repository<",
57
59
  ">Article<",
@@ -27,22 +27,27 @@
27
27
  "div.ledger-poster",
28
28
  "img",
29
29
  "div.ledger-meta",
30
- "span.ledger-status",
31
30
  "h3.ledger-card-title",
32
31
  "a.ledger-card-stretch",
33
32
  "p.ledger-card-copy",
34
33
  "div.ledger-chip-row",
35
34
  "div.ledger-card-links"
36
35
  ],
36
+ "forbidden": [
37
+ "span.ledger-status"
38
+ ],
39
+ "excludes": [
40
+ ">Ongoing<"
41
+ ],
37
42
  "contains": [
38
43
  ">Repository<",
39
44
  ">Article<",
40
45
  ">Visit<",
41
46
  ">ARCO<",
42
- ">Simulation"
47
+ ">Simulation<"
43
48
  ],
44
49
  "counts": {
45
- "ledger-meta-sep": 1,
50
+ "ledger-meta-sep": 0,
46
51
  "ledger-cta": 3
47
52
  }
48
53
  }
@@ -45,6 +45,76 @@ check!(REPO_ROOT.join("studio-app/src/main.js").read.include?("armPipelineWatch"
45
45
  "SPA must watch the content pipeline after save")
46
46
  check!(REPO_ROOT.join("studio-app/src/main.js").read.include?("refreshPipelineStatus"),
47
47
  "SPA must hydrate pipeline status on boot")
48
+
49
+ # Pipeline polls used to call renderShell(), which wipes #app and remounts
50
+ # TipTap. That drops editor focus for the whole run.
51
+ def function_source(source, name)
52
+ re = /(?:async\s+)?function\s+#{Regexp.escape(name)}\s*\(/
53
+ m = source.match(re)
54
+ raise "missing function #{name}" unless m
55
+ i = m.end(0)
56
+ paren = 1
57
+ while i < source.length && paren.positive?
58
+ c = source[i]
59
+ if c == "'" || c == '"' || c == "`"
60
+ q = c
61
+ i += 1
62
+ while i < source.length && source[i] != q
63
+ i += source[i] == "\\" ? 2 : 1
64
+ end
65
+ i += 1
66
+ elsif c == "/" && source[i + 1] == "/"
67
+ nl = source.index("\n", i)
68
+ i = nl ? nl + 1 : source.length
69
+ else
70
+ paren += 1 if c == "("
71
+ paren -= 1 if c == ")"
72
+ i += 1
73
+ end
74
+ end
75
+ i += 1 while i < source.length && source[i] != "{"
76
+ raise "missing body #{name}" if i >= source.length
77
+ depth = 0
78
+ start = i
79
+ while i < source.length
80
+ c = source[i]
81
+ if c == "{"
82
+ depth += 1
83
+ i += 1
84
+ elsif c == "}"
85
+ depth -= 1
86
+ return source[start..i] if depth == 0
87
+ i += 1
88
+ elsif c == "'" || c == '"' || c == "`"
89
+ q = c
90
+ i += 1
91
+ while i < source.length && source[i] != q
92
+ i += source[i] == "\\" ? 2 : 1
93
+ end
94
+ i += 1
95
+ elsif c == "/" && source[i + 1] == "/"
96
+ nl = source.index("\n", i)
97
+ i = nl ? nl + 1 : source.length
98
+ else
99
+ i += 1
100
+ end
101
+ end
102
+ raise "unclosed function #{name}"
103
+ end
104
+
105
+ studio_main = REPO_ROOT.join("studio-app/src/main.js").read
106
+ %w[pollPipelineStatus refreshPipelineStatus armPipelineWatch].each do |fn|
107
+ body = function_source(studio_main, fn)
108
+ check!(!body.include?("renderShell"),
109
+ "#{fn} must not rebuild the shell (that drops editor focus while the chip spins)")
110
+ check!(body.include?("syncPipelineChip"),
111
+ "#{fn} must patch the pipeline chip in place")
112
+ end
113
+ reexport = function_source(studio_main, "reexportLocalContent")
114
+ check!(!reexport.include?("renderShell"),
115
+ "local export must not rebuild the shell while the button spins")
116
+ check!(reexport.include?("syncLocalExportButton"),
117
+ "local export must patch the export button in place")
48
118
  check!(!REPO_ROOT.join("studio-app/src/main.js").read.match?(/action:\s*"delete"/),
49
119
  "toolbar must not include a Delete action")
50
120
 
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * parseContentWorkflowFromConfig — reads pandorga.content.workflow from _config.yml.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { parseContentWorkflowFromConfig } from "../../functions/api/studio/_lib/github.js";
7
+
8
+ const sample = `# site
9
+ title: Example
10
+
11
+ pandorga:
12
+ # comment above content
13
+ content:
14
+ backend: r2
15
+ base_url: https://example.invalid
16
+ # Actions workflow Studio watches after Save
17
+ workflow: content-pipeline.yml
18
+ workflow_ref: main
19
+ identity:
20
+ name: Example
21
+ `;
22
+
23
+ const parsed = parseContentWorkflowFromConfig(sample);
24
+ assert.equal(parsed.workflow, "content-pipeline.yml");
25
+ assert.equal(parsed.workflowRef, "main");
26
+
27
+ const missing = parseContentWorkflowFromConfig("pandorga:\n identity:\n name: x\n");
28
+ assert.equal(missing.workflow, null);
29
+
30
+ const quoted = parseContentWorkflowFromConfig(
31
+ "pandorga:\n content:\n workflow: \"my-workflow.yml\"\n"
32
+ );
33
+ assert.equal(quoted.workflow, "my-workflow.yml");
34
+
35
+ console.log("All Studio pipeline config parse checks passed.");
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pipeline status updates must patch the toolbar chip. Rebuilding the shell
4
+ * drops editor focus for the whole run.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import fs from "node:fs";
8
+ import vm from "node:vm";
9
+ import { fileURLToPath } from "node:url";
10
+ import path from "node:path";
11
+
12
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
13
+ const source = fs.readFileSync(path.join(root, "studio-app/src/main.js"), "utf8");
14
+
15
+ function skipString(i) {
16
+ const q = source[i];
17
+ i += 1;
18
+ while (i < source.length && source[i] !== q) {
19
+ if (source[i] === "\\") i += 2;
20
+ else i += 1;
21
+ }
22
+ return i + 1;
23
+ }
24
+
25
+ function functionSource(name) {
26
+ const re = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\(`);
27
+ const m = re.exec(source);
28
+ if (!m) throw new Error(`missing function ${name}`);
29
+ let i = m.index + m[0].length;
30
+ let paren = 1;
31
+ while (i < source.length && paren > 0) {
32
+ const c = source[i];
33
+ if (c === "'" || c === '"' || c === "`") i = skipString(i);
34
+ else if (c === "/" && source[i + 1] === "/") {
35
+ const nl = source.indexOf("\n", i);
36
+ i = nl === -1 ? source.length : nl + 1;
37
+ } else {
38
+ if (c === "(") paren += 1;
39
+ else if (c === ")") paren -= 1;
40
+ i += 1;
41
+ }
42
+ }
43
+ while (i < source.length && source[i] !== "{") i += 1;
44
+ if (i >= source.length) throw new Error(`missing body ${name}`);
45
+ let depth = 0;
46
+ while (i < source.length) {
47
+ const c = source[i];
48
+ if (c === "{") {
49
+ depth += 1;
50
+ i += 1;
51
+ } else if (c === "}") {
52
+ depth -= 1;
53
+ if (depth === 0) return source.slice(m.index, i + 1);
54
+ i += 1;
55
+ } else if (c === "'" || c === '"' || c === "`") {
56
+ i = skipString(i);
57
+ } else if (c === "/" && source[i + 1] === "/") {
58
+ const nl = source.indexOf("\n", i);
59
+ i = nl === -1 ? source.length : nl + 1;
60
+ } else {
61
+ i += 1;
62
+ }
63
+ }
64
+ throw new Error(`unclosed function ${name}`);
65
+ }
66
+
67
+ class MiniNode {
68
+ constructor(tag) {
69
+ this.nodeType = 1;
70
+ this.tagName = String(tag || "").toUpperCase();
71
+ this.childNodes = [];
72
+ this.parentNode = null;
73
+ this.attributes = new Map();
74
+ this.className = "";
75
+ this.disabled = false;
76
+ this.title = "";
77
+ this.value = "";
78
+ this.selectionStart = 0;
79
+ this.selectionEnd = 0;
80
+ this._text = "";
81
+ this.listeners = new Map();
82
+ const node = this;
83
+ this.classList = {
84
+ toggle(name, force) {
85
+ const parts = node.className.split(/\s+/).filter(Boolean);
86
+ const has = parts.includes(name);
87
+ const on = force === undefined ? !has : !!force;
88
+ const next = parts.filter((part) => part !== name);
89
+ if (on) next.push(name);
90
+ node.className = next.join(" ");
91
+ return on;
92
+ },
93
+ };
94
+ }
95
+
96
+ get textContent() {
97
+ if (this.childNodes.length === 0) return this._text;
98
+ return this.childNodes.map((child) => child.textContent).join("");
99
+ }
100
+
101
+ set textContent(value) {
102
+ this._text = value == null ? "" : String(value);
103
+ this.childNodes = [];
104
+ }
105
+
106
+ setAttribute(name, value) {
107
+ this.attributes.set(name, String(value));
108
+ }
109
+
110
+ getAttribute(name) {
111
+ return this.attributes.has(name) ? this.attributes.get(name) : null;
112
+ }
113
+
114
+ addEventListener(type, fn) {
115
+ this.listeners.set(type, fn);
116
+ }
117
+
118
+ append(...kids) {
119
+ for (const kid of kids) {
120
+ if (kid == null || kid === false) continue;
121
+ kid.parentNode = this;
122
+ this.childNodes.push(kid);
123
+ }
124
+ }
125
+
126
+ replaceWith(next) {
127
+ const parent = this.parentNode;
128
+ if (!parent) return;
129
+ const idx = parent.childNodes.indexOf(this);
130
+ if (idx < 0) return;
131
+ next.parentNode = parent;
132
+ parent.childNodes[idx] = next;
133
+ this.parentNode = null;
134
+ if (document.activeElement === this) document.activeElement = document.body;
135
+ }
136
+
137
+ querySelector(sel) {
138
+ return querySelector(this, sel);
139
+ }
140
+
141
+ focus() {
142
+ document.activeElement = this;
143
+ }
144
+
145
+ setSelectionRange(start, end) {
146
+ this.selectionStart = start;
147
+ this.selectionEnd = end;
148
+ }
149
+ }
150
+
151
+ function matches(node, sel) {
152
+ const attr = sel.match(/^\[([^=]+)='([^']*)'\]$/);
153
+ if (attr) return node.getAttribute(attr[1]) === attr[2];
154
+ if (sel.startsWith(".")) {
155
+ const name = sel.slice(1);
156
+ return node.className.split(/\s+/).includes(name);
157
+ }
158
+ return false;
159
+ }
160
+
161
+ function querySelector(root, sel) {
162
+ for (const child of root.childNodes || []) {
163
+ if (child.nodeType === 1 && matches(child, sel)) return child;
164
+ const found = querySelector(child, sel);
165
+ if (found) return found;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ const document = {
171
+ activeElement: null,
172
+ body: null,
173
+ createElement(tag) {
174
+ return new MiniNode(tag);
175
+ },
176
+ querySelector(sel) {
177
+ return querySelector(document.body, sel);
178
+ },
179
+ };
180
+ document.body = new MiniNode("body");
181
+ document.body.parentNode = document;
182
+
183
+ const editor = new MiniNode("textarea");
184
+ editor.value = "keep typing";
185
+ editor.className = "body-editor";
186
+ const chipHost = new MiniNode("div");
187
+ document.body.append(chipHost, editor);
188
+
189
+ const state = {
190
+ pipeline: {
191
+ state: "idle",
192
+ sha: "",
193
+ runUrl: "",
194
+ message: "",
195
+ pollGen: 0,
196
+ },
197
+ reexporting: false,
198
+ error: "",
199
+ errorDetail: "",
200
+ status: "",
201
+ file: "content/collections/articles/hello.md",
202
+ saving: false,
203
+ dirty: false,
204
+ };
205
+
206
+ let statusCalls = 0;
207
+ const studioApi = {
208
+ async pipelineStatus() {
209
+ statusCalls += 1;
210
+ if (statusCalls < 3) {
211
+ return {
212
+ state: "running",
213
+ sha: "abc",
214
+ run: { html_url: "https://example.com/actions/runs/9" },
215
+ };
216
+ }
217
+ return {
218
+ state: "ok",
219
+ sha: "abc",
220
+ run: { html_url: "https://example.com/actions/runs/9" },
221
+ };
222
+ },
223
+ };
224
+
225
+ const context = {
226
+ state,
227
+ document,
228
+ studioApi,
229
+ sleep: async () => {},
230
+ console,
231
+ JSON,
232
+ String,
233
+ window: { open() {}, location: { hostname: "studio.example.com" } },
234
+ };
235
+ vm.createContext(context);
236
+ vm.runInContext(
237
+ [
238
+ functionSource("el"),
239
+ functionSource("pipelineChipMeta"),
240
+ functionSource("pipelineChipViewKey"),
241
+ functionSource("renderPipelineChip"),
242
+ functionSource("syncPipelineChip"),
243
+ functionSource("applyPipelinePayload"),
244
+ functionSource("localExportPresentation"),
245
+ functionSource("syncLocalExportButton"),
246
+ functionSource("pollPipelineStatus"),
247
+ ].join("\n"),
248
+ context
249
+ );
250
+
251
+ chipHost.append(vm.runInContext("renderPipelineChip()", context));
252
+ editor.focus();
253
+ editor.setSelectionRange(5, 5);
254
+ const editorAtFocus = document.activeElement;
255
+
256
+ await vm.runInContext(
257
+ `(async () => {
258
+ state.pipeline.pollGen = 1;
259
+ state.pipeline.state = "awaiting_run";
260
+ syncPipelineChip();
261
+ await pollPipelineStatus(1, "abc");
262
+ })()`,
263
+ context
264
+ );
265
+
266
+ assert.equal(document.activeElement, editorAtFocus, "editor focus survives pipeline polls");
267
+ assert.equal(editor.parentNode, document.body, "editor stays mounted");
268
+ assert.equal(editor.value, "keep typing");
269
+ assert.equal(editor.selectionStart, 5);
270
+ assert.equal(state.pipeline.state, "ok");
271
+
272
+ const chip = document.querySelector("[data-studio-action='pipeline-status']");
273
+ assert.ok(chip, "pipeline chip remains");
274
+ assert.match(chip.className, /pipeline-ok/);
275
+ assert.equal(chip.querySelector(".btn-label").textContent, "Live");
276
+ assert.equal(chip.querySelector(".studio-icon-spin"), null);
277
+ assert.equal(chip.disabled, false);
278
+
279
+ editor.focus();
280
+ state.reexporting = true;
281
+ state.pipeline.state = "syncing";
282
+ vm.runInContext("syncLocalExportButton()", context);
283
+ const exportBtn = document.querySelector("[data-studio-action='reexport']");
284
+ assert.equal(exportBtn, null, "production shell has no local export button");
285
+
286
+ const exportHost = new MiniNode("button");
287
+ exportHost.setAttribute("data-studio-action", "reexport");
288
+ exportHost.className = "btn btn-tool btn-reexport";
289
+ const icon = new MiniNode("span");
290
+ icon.className = "material-symbols-outlined";
291
+ icon.textContent = "sync";
292
+ const label = new MiniNode("span");
293
+ label.className = "btn-label";
294
+ label.textContent = "Export";
295
+ exportHost.append(icon, label);
296
+ document.body.append(exportHost);
297
+
298
+ editor.focus();
299
+ vm.runInContext("syncLocalExportButton()", context);
300
+ assert.equal(document.activeElement, editor, "local export spin does not steal editor focus");
301
+ assert.equal(exportHost.disabled, true);
302
+ assert.match(exportHost.className, /pipeline-running/);
303
+ assert.equal(icon.textContent, "progress_activity");
304
+ assert.match(icon.className, /studio-icon-spin/);
305
+ assert.equal(label.textContent, "Exporting…");
306
+ assert.equal(editor.value, "keep typing");
307
+
308
+ console.log("All Studio pipeline focus checks passed.");
data/scripts/validate.sh CHANGED
@@ -68,6 +68,16 @@ for test in "${TESTS[@]}"; do
68
68
  fi
69
69
  done
70
70
 
71
+ echo "-- scripts/test/test-studio-pipeline-config.mjs"
72
+ if ! node scripts/test/test-studio-pipeline-config.mjs; then
73
+ failed=1
74
+ fi
75
+
76
+ echo "-- scripts/test/test-studio-pipeline-focus.mjs"
77
+ if ! node scripts/test/test-studio-pipeline-focus.mjs; then
78
+ failed=1
79
+ fi
80
+
71
81
  if [[ "$failed" -ne 0 ]]; then
72
82
  echo "VALIDATE FAILED"
73
83
  exit 1