@1agh/maude 0.41.0 → 0.42.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 (39) hide show
  1. package/apps/studio/api.ts +0 -41
  2. package/apps/studio/bin/_html-playwright.mjs +26 -4
  3. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  4. package/apps/studio/bin/_png-playwright.mjs +15 -2
  5. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  6. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  7. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  8. package/apps/studio/bin/screenshot.sh +53 -4
  9. package/apps/studio/client/app.jsx +25 -54
  10. package/apps/studio/client/export-center.jsx +426 -0
  11. package/apps/studio/client/styles/3-shell-maude.css +17 -0
  12. package/apps/studio/client/styles/4-components.css +150 -0
  13. package/apps/studio/config.schema.json +2 -2
  14. package/apps/studio/dist/client.bundle.js +17 -17
  15. package/apps/studio/dist/styles.css +1 -1
  16. package/apps/studio/export-dialog.tsx +18 -25
  17. package/apps/studio/exporters/_runtime.ts +104 -0
  18. package/apps/studio/exporters/html.ts +12 -20
  19. package/apps/studio/exporters/index.ts +14 -2
  20. package/apps/studio/exporters/jobs.ts +334 -0
  21. package/apps/studio/exporters/pdf.ts +16 -20
  22. package/apps/studio/exporters/png.ts +12 -20
  23. package/apps/studio/exporters/pptx.ts +22 -23
  24. package/apps/studio/exporters/scope.ts +1 -0
  25. package/apps/studio/exporters/svg.ts +14 -22
  26. package/apps/studio/exporters/video.ts +15 -17
  27. package/apps/studio/git/service.ts +3 -1
  28. package/apps/studio/http.ts +145 -50
  29. package/apps/studio/server.ts +3 -1
  30. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  31. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  32. package/apps/studio/test/export-center.test.tsx +287 -0
  33. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  34. package/apps/studio/test/exporters/history.test.ts +32 -3
  35. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  36. package/apps/studio/whats-new.json +17 -0
  37. package/apps/studio/ws.ts +6 -0
  38. package/cli/lib/gitignore-block.mjs +1 -0
  39. package/package.json +8 -8
@@ -180,16 +180,6 @@ export interface GitCommitter {
180
180
  commits: number;
181
181
  }
182
182
 
183
- // Phase 6.5 T10 — export history. Five-deep ring buffer of recent exports
184
- // surfaced by the dialog's "Recent" tab + ⌘⇧E re-run.
185
- export interface ExportHistoryEntry {
186
- format: string;
187
- scope: string;
188
- options?: Record<string, unknown>;
189
- filename: string;
190
- at: string;
191
- }
192
-
193
183
  export type CreateCanvasResult =
194
184
  | { ok: true; file: string; rel: string; slug: string }
195
185
  | { ok: false; status: number; error: string };
@@ -465,9 +455,6 @@ export interface Api {
465
455
  // Aggregate data
466
456
  buildIndexData(): Promise<unknown>;
467
457
  buildSystemData(dsName?: string | null): Promise<unknown>;
468
- // Export history (Phase 6.5 T10)
469
- loadExportHistory(): Promise<ExportHistoryEntry[]>;
470
- appendExportHistory(entry: ExportHistoryEntry): Promise<void>;
471
458
  }
472
459
 
473
460
  export interface ApiHooks {
@@ -3006,32 +2993,6 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3006
2993
  };
3007
2994
  }
3008
2995
 
3009
- // ---------- Export history (Phase 6.5 T10) ----------
3010
- //
3011
- // 5-deep ring buffer persisted at `<designRoot>/_export-history.json`.
3012
- // Reads tolerate missing / malformed files (returns []). Writes truncate
3013
- // to most-recent-first.
3014
-
3015
- const HISTORY_PATH = path.join(paths.designRoot, '_export-history.json');
3016
- const HISTORY_DEPTH = 5;
3017
-
3018
- async function loadExportHistory(): Promise<ExportHistoryEntry[]> {
3019
- try {
3020
- const raw = await Bun.file(HISTORY_PATH).text();
3021
- const arr = JSON.parse(raw);
3022
- if (!Array.isArray(arr)) return [];
3023
- return arr.slice(0, HISTORY_DEPTH);
3024
- } catch {
3025
- return [];
3026
- }
3027
- }
3028
-
3029
- async function appendExportHistory(entry: ExportHistoryEntry): Promise<void> {
3030
- const prev = await loadExportHistory();
3031
- const next = [entry, ...prev].slice(0, HISTORY_DEPTH);
3032
- await Bun.write(HISTORY_PATH, JSON.stringify(next, null, 2));
3033
- }
3034
-
3035
2996
  function tokenKind(name: string, value: string): string {
3036
2997
  const n = name.toLowerCase();
3037
2998
  const v = String(value).trim();
@@ -3273,7 +3234,5 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3273
3234
  reorderRevert,
3274
3235
  buildIndexData,
3275
3236
  buildSystemData,
3276
- loadExportHistory,
3277
- appendExportHistory,
3278
3237
  };
3279
3238
  }
@@ -62,14 +62,31 @@ try {
62
62
  world.style.zoom = '1';
63
63
  world.style.transform = 'none';
64
64
  }
65
- for (const el of document.querySelectorAll('[data-dc-screen]')) {
66
- el.style.left = '0px';
67
- el.style.top = '0px';
68
- }
69
65
  });
70
66
 
71
67
  const written = [];
72
68
 
69
+ // Pin one artboard to (0,0) for its capture, returning a restore function.
70
+ // Per-target rather than a one-shot reset of every `[data-dc-screen]` — a
71
+ // multi-target loop must not leave an already-captured artboard sitting at
72
+ // the origin, overlapping the next target's geometry (the scatter bug).
73
+ const pinArtboard = async (handle) => {
74
+ const saved = await handle.evaluate((el) => {
75
+ const ab = el.closest('[data-dc-screen]') ?? el;
76
+ const prev = { left: ab.style.left, top: ab.style.top };
77
+ ab.style.left = '0px';
78
+ ab.style.top = '0px';
79
+ return prev;
80
+ });
81
+ return async () => {
82
+ await handle.evaluate((el, prev) => {
83
+ const ab = el.closest('[data-dc-screen]') ?? el;
84
+ ab.style.left = prev.left;
85
+ ab.style.top = prev.top;
86
+ }, saved);
87
+ };
88
+ };
89
+
73
90
  if (multi) {
74
91
  if (!outDir) {
75
92
  console.error('_html-playwright: --multi requires --out-dir');
@@ -80,10 +97,13 @@ try {
80
97
  for (let i = 0; i < screens.length; i += 1) {
81
98
  const handle = screens[i];
82
99
  const id = (await handle.getAttribute('data-dc-screen')) ?? `artboard-${i + 1}`;
100
+ const restore = await pinArtboard(handle);
83
101
  const html = await serializeOne(handle, false);
102
+ await restore();
84
103
  const target = join(outDir, `${id}.html`);
85
104
  writeFileSync(target, html, 'utf8');
86
105
  written.push(target);
106
+ console.log(`MAUDE_PROGRESS {"current":${i + 1},"total":${screens.length}}`);
87
107
  }
88
108
  } else {
89
109
  if (!out) {
@@ -93,7 +113,9 @@ try {
93
113
  mkdirSync(dirname(out), { recursive: true });
94
114
  const handle = page.locator(selector ?? '[data-dc-screen]:first-of-type').first();
95
115
  await handle.waitFor({ state: 'visible', timeout: timeoutMs });
116
+ const restore = await pinArtboard(handle);
96
117
  const html = await serializeOne(handle, widen);
118
+ await restore();
97
119
  writeFileSync(out, html, 'utf8');
98
120
  written.push(out);
99
121
  }
@@ -88,11 +88,15 @@ try {
88
88
  : screens[i];
89
89
  // Pin the captured element's own artboard to (0,0) right before its
90
90
  // capture so the world's multi-artboard layout doesn't push the bbox off
91
- // the viewport.
92
- await handle.evaluate((el) => {
91
+ // the viewport. Save the prior value so it can be restored right after
92
+ // this artboard's PDF is written — otherwise every artboard captured
93
+ // earlier in the loop stays stacked at (0,0) and bleeds into later pages.
94
+ const savedPos = await handle.evaluate((el) => {
93
95
  const ab = el.closest('[data-dc-screen]') ?? el;
96
+ const prev = { left: ab.style.left, top: ab.style.top };
94
97
  ab.style.left = '0px';
95
98
  ab.style.top = '0px';
99
+ return prev;
96
100
  });
97
101
  const rect = await handle.evaluate((el) => {
98
102
  const r = el.getBoundingClientRect();
@@ -120,6 +124,13 @@ try {
120
124
  });
121
125
  writeFileSync(targetPath, pdf);
122
126
  written.push(targetPath);
127
+ // Restore the artboard's original position now that its page is written.
128
+ await handle.evaluate((el, prev) => {
129
+ const ab = el.closest('[data-dc-screen]') ?? el;
130
+ ab.style.left = prev.left;
131
+ ab.style.top = prev.top;
132
+ }, savedPos);
133
+ if (multi) console.log(`MAUDE_PROGRESS {"current":${i + 1},"total":${screens.length}}`);
123
134
  }
124
135
 
125
136
  for (const w of written) console.log(w);
@@ -82,11 +82,16 @@ try {
82
82
  // artboard #2 (or any non-first selection's host) left the wrong artboard
83
83
  // at the origin (item 5 root cause). Each artboard carries
84
84
  // `style="left:…; top:…;"` for the world layout — zero the right one so the
85
- // screenshot clip starts at the viewport origin.
86
- await widenedHandle.evaluate((el) => {
85
+ // screenshot clip starts at the viewport origin. Save the prior value so it
86
+ // can be restored after this artboard's capture — otherwise every artboard
87
+ // processed earlier in a `--multi` loop is left stacked at (0,0) and bleeds
88
+ // into every subsequent target's clip region (the scatter bug).
89
+ const savedPos = await widenedHandle.evaluate((el) => {
87
90
  const ab = el.closest('[data-dc-screen]') ?? el;
91
+ const prev = { left: ab.style.left, top: ab.style.top };
88
92
  ab.style.left = '0px';
89
93
  ab.style.top = '0px';
94
+ return prev;
90
95
  });
91
96
  const rect = await widenedHandle.evaluate((el) => {
92
97
  const r = el.getBoundingClientRect();
@@ -118,6 +123,13 @@ try {
118
123
  },
119
124
  });
120
125
  written.push(target);
126
+ // Restore the artboard's original position now that its capture is done,
127
+ // so it doesn't stay pinned at (0,0) and corrupt the next target's clip.
128
+ await widenedHandle.evaluate((el, prev) => {
129
+ const ab = el.closest('[data-dc-screen]') ?? el;
130
+ ab.style.left = prev.left;
131
+ ab.style.top = prev.top;
132
+ }, savedPos);
121
133
  };
122
134
 
123
135
  if (multi) {
@@ -131,6 +143,7 @@ try {
131
143
  const handle = screens[i];
132
144
  const id = (await handle.getAttribute('data-dc-screen')) ?? `artboard-${i + 1}`;
133
145
  await captureHandle(handle, join(outDir, `${id}.png`));
146
+ console.log(`MAUDE_PROGRESS {"current":${i + 1},"total":${screens.length}}`);
134
147
  }
135
148
  } else {
136
149
  if (!out) {
@@ -48,10 +48,6 @@ try {
48
48
  world.style.zoom = '1';
49
49
  world.style.transform = 'none';
50
50
  }
51
- for (const el of document.querySelectorAll('[data-dc-screen]')) {
52
- el.style.left = '0px';
53
- el.style.top = '0px';
54
- }
55
51
  });
56
52
  // dom-to-pptx ships a UMD bundle; addScriptTag exposes `window.domToPptx`.
57
53
  await page.addScriptTag({ path: bundlePath });
@@ -74,6 +70,17 @@ try {
74
70
  }
75
71
  await handle.waitFor({ state: 'visible', timeout: timeoutMs });
76
72
 
73
+ // Pin this artboard to (0,0) for its capture, restoring afterwards so a
74
+ // future multi-slide implementation doesn't inherit a stacked-at-origin bug
75
+ // (the scatter bug fixed in the png/pdf/svg/html shims).
76
+ const savedPos = await handle.evaluate((el) => {
77
+ const ab = el.closest('[data-dc-screen]') ?? el;
78
+ const prev = { left: ab.style.left, top: ab.style.top };
79
+ ab.style.left = '0px';
80
+ ab.style.top = '0px';
81
+ return prev;
82
+ });
83
+
77
84
  // Run dom-to-pptx inside the page. It returns a Blob; we serialise to a
78
85
  // byte array for transport across the playwright boundary.
79
86
  const bytesArray = await handle.evaluate(async (el) => {
@@ -88,6 +95,12 @@ try {
88
95
  return Array.from(new Uint8Array(ab));
89
96
  });
90
97
 
98
+ await handle.evaluate((el, prev) => {
99
+ const ab = el.closest('[data-dc-screen]') ?? el;
100
+ ab.style.left = prev.left;
101
+ ab.style.top = prev.top;
102
+ }, savedPos);
103
+
91
104
  writeFileSync(out, new Uint8Array(bytesArray));
92
105
  console.log(out);
93
106
  console.error(
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Invocation (called by screenshot.sh — not directly by users):
7
7
  // npm exec --package=playwright -- node _screenshot-playwright.mjs \
8
- // --url <url> [--selector <css>] --out <path> [--timeout 8]
8
+ // --url <url> [--selector <css>] --out <path> [--timeout 8] [--theme <name>]
9
9
  //
10
10
  // First invocation may install chromium (~150 MB). Subsequent runs reuse cache.
11
11
 
@@ -20,7 +20,7 @@ const args = Object.fromEntries(
20
20
  }, [])
21
21
  );
22
22
 
23
- const { url, selector, out, timeout = '8' } = args;
23
+ const { url, selector, out, timeout = '8', theme } = args;
24
24
  if (!url || !out) {
25
25
  console.error(
26
26
  'usage: _screenshot-playwright.mjs --url <url> [--selector <css>] --out <path> [--timeout 8]'
@@ -37,6 +37,21 @@ try {
37
37
  const page = await ctx.newPage();
38
38
  await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
39
39
 
40
+ if (theme) {
41
+ // Dual-theme reality check (/design:new step 9): force every DS artboard
42
+ // theme wrapper to `theme` before capture, deterministically, instead of
43
+ // relying on whatever the canvas is pinned to. No-op if the canvas has no
44
+ // `[data-theme]` elements (e.g. a single-theme DS).
45
+ const n = await page.evaluate((t) => {
46
+ const els = document.querySelectorAll('[data-theme]');
47
+ els.forEach((el) => {
48
+ el.setAttribute('data-theme', t);
49
+ });
50
+ return els.length;
51
+ }, theme);
52
+ console.error(`→ theme override: forced data-theme="${theme}" on ${n} element(s)`);
53
+ }
54
+
40
55
  if (selector) {
41
56
  const loc = page.locator(selector).first();
42
57
  await loc.waitFor({ state: 'visible', timeout: timeoutMs });
@@ -70,10 +70,6 @@ try {
70
70
  world.style.zoom = '1';
71
71
  world.style.transform = 'none';
72
72
  }
73
- for (const el of document.querySelectorAll('[data-dc-screen]')) {
74
- el.style.left = '0px';
75
- el.style.top = '0px';
76
- }
77
73
  });
78
74
  // Inject dom-to-svg into the page. Bundle attaches its exports under
79
75
  // `window.domToSvg`.
@@ -81,6 +77,27 @@ try {
81
77
 
82
78
  const written = [];
83
79
 
80
+ // Pin one artboard to (0,0) for its capture, returning a restore function.
81
+ // Per-target rather than a one-shot reset of every `[data-dc-screen]` — a
82
+ // multi-target loop must not leave an already-captured artboard sitting at
83
+ // the origin, overlapping the next target's geometry (the scatter bug).
84
+ const pinArtboard = async (handle) => {
85
+ const saved = await handle.evaluate((el) => {
86
+ const ab = el.closest('[data-dc-screen]') ?? el;
87
+ const prev = { left: ab.style.left, top: ab.style.top };
88
+ ab.style.left = '0px';
89
+ ab.style.top = '0px';
90
+ return prev;
91
+ });
92
+ return async () => {
93
+ await handle.evaluate((el, prev) => {
94
+ const ab = el.closest('[data-dc-screen]') ?? el;
95
+ ab.style.left = prev.left;
96
+ ab.style.top = prev.top;
97
+ }, saved);
98
+ };
99
+ };
100
+
84
101
  // Single in-page serializer used by BOTH branches (single + multi) so they
85
102
  // can't drift again (the `formatXML` 500 bug came from a divergent copy).
86
103
  // Note: dom-to-svg exports only elementToSVG / inlineResources /
@@ -228,11 +245,14 @@ try {
228
245
  for (let i = 0; i < screens.length; i += 1) {
229
246
  const handle = screens[i];
230
247
  const id = (await handle.getAttribute('data-dc-screen')) ?? `artboard-${i + 1}`;
248
+ const restore = await pinArtboard(handle);
231
249
  // Each multi handle is already a `[data-dc-screen]` artboard — no widen.
232
250
  const svg = await serializeOne(handle, false);
251
+ await restore();
233
252
  const target = join(outDir, `${id}.svg`);
234
253
  writeFileSync(target, svg, 'utf8');
235
254
  written.push(target);
255
+ console.log(`MAUDE_PROGRESS {"current":${i + 1},"total":${screens.length}}`);
236
256
  }
237
257
  } else {
238
258
  if (!out) {
@@ -242,7 +262,9 @@ try {
242
262
  mkdirSync(dirname(out), { recursive: true });
243
263
  const handle = page.locator(selector ?? '[data-dc-screen]:first-of-type').first();
244
264
  await handle.waitFor({ state: 'visible', timeout: timeoutMs });
265
+ const restore = await pinArtboard(handle);
245
266
  const svg = await serializeOne(handle, widen);
267
+ await restore();
246
268
  writeFileSync(out, svg, 'utf8');
247
269
  written.push(out);
248
270
  }
@@ -9,12 +9,18 @@
9
9
  # [--screen <id> | --element <id> | --selector <css> | --full]
10
10
  # [--all-screens] [--out <path>] [--out-dir <dir>]
11
11
  # [--timeout 8] [--engine auto|agent-browser|playwright]
12
- # [--root <repo>]
12
+ # [--theme <name>] [--root <repo>]
13
13
  #
14
14
  # Notes:
15
15
  # - Exactly one of --screen / --element / --selector / --full is required
16
16
  # (or --all-screens which loops over every [data-dc-screen]/[data-dc-slot]).
17
17
  # - --out required for single-shot modes; --out-dir required for --all-screens.
18
+ # - --theme <name> forces every `[data-theme]` element (DS artboard wrappers)
19
+ # to that value BEFORE capture, via a DOM eval — does not touch the actual
20
+ # canvas file or the running server's state. Used to capture a DS's
21
+ # alternate theme deterministically for the dual-theme reality check
22
+ # (/design:new step 9) instead of relying on whatever the canvas is pinned
23
+ # to. No-op if the canvas has no `[data-theme]` elements.
18
24
  # - URL resolution: --url > --port + _active.json > _server.json + _active.json.
19
25
  # - Stdout: written PNG paths, one per line (composable in for-loops).
20
26
  # - Stderr: diagnostic, engine choice, timing.
@@ -30,6 +36,7 @@ TIMEOUT=8
30
36
  ENGINE="auto"
31
37
  ALL_SCREENS=0
32
38
  ROOT=""
39
+ THEME=""
33
40
 
34
41
  while [ $# -gt 0 ]; do
35
42
  case "$1" in
@@ -44,9 +51,10 @@ while [ $# -gt 0 ]; do
44
51
  --out-dir) OUT_DIR="$2"; shift 2 ;;
45
52
  --timeout) TIMEOUT="$2"; shift 2 ;;
46
53
  --engine) ENGINE="$2"; shift 2 ;;
54
+ --theme) THEME="$2"; shift 2 ;;
47
55
  --root) ROOT="$2"; shift 2 ;;
48
56
  --help|-h)
49
- sed -n '2,22p' "$0" | sed 's/^# \?//'
57
+ sed -n '2,27p' "$0" | sed 's/^# \?//'
50
58
  exit 0
51
59
  ;;
52
60
  *)
@@ -57,6 +65,20 @@ while [ $# -gt 0 ]; do
57
65
  done
58
66
 
59
67
  # ---------- arg validation ----------
68
+ # --theme ultimately traces back to config.json's designSystems[].themes[]
69
+ # (untrusted, same posture as prep.sh's MOODBOARD_VARIANTS). Callers (e.g.
70
+ # new.md step 9) build an --out-dir path from this value — reject anything
71
+ # outside a safe slug charset here too, at the shared sink, rather than
72
+ # trusting every caller to re-derive this guard.
73
+ if [ -n "$THEME" ]; then
74
+ case "$THEME" in
75
+ *[!A-Za-z0-9._-]*|"")
76
+ echo "screenshot.sh: --theme '$THEME' contains unsafe characters (allowed: A-Za-z0-9._-)" >&2
77
+ exit 2
78
+ ;;
79
+ esac
80
+ fi
81
+
60
82
  if [ $ALL_SCREENS -eq 1 ]; then
61
83
  [ -z "$OUT_DIR" ] && { echo "screenshot.sh: --all-screens needs --out-dir" >&2; exit 2; }
62
84
  else
@@ -189,10 +211,12 @@ pw_screenshot() {
189
211
  return 1
190
212
  fi
191
213
  echo "→ playwright engine; first invocation may install chromium (~150MB, one-off)" >&2
214
+ local theme_args=()
215
+ [ -n "$THEME" ] && theme_args=(--theme "$THEME")
192
216
  if [ -n "$css" ]; then
193
- npm exec --yes --package=playwright -- node "$pw_script" --url "$URL" --selector "$css" --out "$out" --timeout "$TIMEOUT" >&2 || return 1
217
+ npm exec --yes --package=playwright -- node "$pw_script" --url "$URL" --selector "$css" --out "$out" --timeout "$TIMEOUT" "${theme_args[@]}" >&2 || return 1
194
218
  else
195
- npm exec --yes --package=playwright -- node "$pw_script" --url "$URL" --out "$out" --timeout "$TIMEOUT" >&2 || return 1
219
+ npm exec --yes --package=playwright -- node "$pw_script" --url "$URL" --out "$out" --timeout "$TIMEOUT" "${theme_args[@]}" >&2 || return 1
196
220
  fi
197
221
  [ -s "$out" ] || { echo "✗ playwright wrote no file: $out" >&2; return 1; }
198
222
  return 0
@@ -224,9 +248,34 @@ navigate_once() {
224
248
  echo "→ no DC mount detected after ${TIMEOUT}s — proceeding (may be a non-canvas page)" >&2
225
249
  sleep 1
226
250
  fi
251
+ apply_theme_override
227
252
  fi
228
253
  }
229
254
 
255
+ # ---------- --theme override (dual-theme reality check) ----------
256
+ # Forces every element carrying a `data-theme` attribute (DS artboard theme
257
+ # wrappers) to $THEME, so the caller can deterministically capture a DS's
258
+ # alternate theme instead of whatever the canvas happens to be pinned to.
259
+ # agent-browser only — the playwright path applies it inside
260
+ # _screenshot-playwright.mjs (passed via --theme) since that shim owns its own
261
+ # page.evaluate. No-op when --theme wasn't passed or no [data-theme] elements
262
+ # exist (e.g. a single-theme DS or a non-canvas page).
263
+ apply_theme_override() {
264
+ [ -z "$THEME" ] && return 0
265
+ [ "$ENGINE" = "agent-browser" ] || return 0
266
+ # THEME ultimately traces back to config.json's designSystems[].themes[]
267
+ # array — treated as untrusted input elsewhere in this codebase (see
268
+ # prep.sh's MOODBOARD_VARIANTS clamp). JSON-encode it via jq so it lands as
269
+ # a properly-escaped JS string literal in the eval'd expression below,
270
+ # instead of naively splicing the raw value into a JS string (which a
271
+ # crafted config value could break out of).
272
+ local theme_json
273
+ theme_json=$(jq -Rn --arg t "$THEME" '$t' 2>/dev/null) || return 0
274
+ local n
275
+ n=$("$AB" eval "(function(t){var els=document.querySelectorAll('[data-theme]');els.forEach(function(el){el.setAttribute('data-theme',t)});return els.length})($theme_json)" 2>/dev/null)
276
+ echo "→ theme override: forced data-theme=\"$THEME\" on $(printf '%s' "$n" | tr -d '[:space:]') element(s)" >&2
277
+ }
278
+
230
279
  capture() {
231
280
  local css="$1"
232
281
  local out="$2"
@@ -29,11 +29,11 @@ import {
29
29
  onUpdateReady,
30
30
  pickMediaFile,
31
31
  restartToUpdate,
32
- saveExport,
33
32
  } from './github.js';
34
33
  import { COLLAB_TOUR } from './tour/collab-tour.js';
35
34
  import { TourOverlay } from './tour/overlay.jsx';
36
35
  import { USAGE_TOUR } from './tour/usage-tour.js';
36
+ import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
37
37
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
38
38
 
39
39
  const USAGE_TOUR_STORE = 'mdcc-usage-tour-seen';
@@ -1079,7 +1079,10 @@ function ExportDialog({
1079
1079
  if (activeArtboardId) options.artboardId = activeArtboardId;
1080
1080
  if (selection?.selector) options.selection = selection;
1081
1081
  try {
1082
- const r = await fetch('/_api/export', {
1082
+ // feature-background-export-notification-center enqueue and close
1083
+ // immediately; the menubar notification center owns status, progress,
1084
+ // and completion (download / native Save…) from here on.
1085
+ const r = await fetch('/_api/export-jobs', {
1083
1086
  method: 'POST',
1084
1087
  headers: { 'content-type': 'application/json' },
1085
1088
  body: JSON.stringify({ format: card.format, scope, options }),
@@ -1089,38 +1092,11 @@ function ExportDialog({
1089
1092
  setBusy(false);
1090
1093
  return;
1091
1094
  }
1092
- const disp = r.headers.get('Content-Disposition') || '';
1093
- const fn = /filename="([^"]+)"/.exec(disp);
1094
- const filename = (fn && fn[1]) || `export.${card.format}`;
1095
- const blob = await r.blob();
1096
- if (isNativeApp()) {
1097
- // Native: WKWebView swallows the `<a download>` blob (the file lands in
1098
- // an unknown default with no prompt). Route through a real OS save dialog
1099
- // so the export OFFERS a location. Cancel → soft no-op.
1100
- const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
1101
- const savedPath = await saveExport(filename, bytes);
1102
- if (savedPath) {
1103
- setStatus({ ok: true, msg: `Saved to ${savedPath}` });
1104
- loadRecent();
1105
- } else {
1106
- setStatus({ ok: true, msg: 'Save cancelled' });
1107
- }
1108
- } else {
1109
- const url = URL.createObjectURL(blob);
1110
- const a = document.createElement('a');
1111
- a.href = url;
1112
- a.download = filename;
1113
- document.body.appendChild(a);
1114
- a.click();
1115
- a.remove();
1116
- URL.revokeObjectURL(url);
1117
- setStatus({ ok: true, msg: `Exported ${filename}` });
1118
- loadRecent();
1119
- }
1095
+ onClose();
1120
1096
  } catch (err) {
1121
1097
  setStatus({ ok: false, msg: err && err.message ? err.message : String(err) });
1098
+ setBusy(false);
1122
1099
  }
1123
- setBusy(false);
1124
1100
  }
1125
1101
 
1126
1102
  return (
@@ -2680,6 +2656,7 @@ function Menubar({
2680
2656
  onOpenWhatsNew,
2681
2657
  onOpenReadiness,
2682
2658
  whatsNewCount,
2659
+ exportCenter,
2683
2660
  artboardCount = 0,
2684
2661
  presence = null,
2685
2662
  inspectorOpen,
@@ -2993,6 +2970,7 @@ function Menubar({
2993
2970
  <StIcon name="sparkle" size={15} />
2994
2971
  </button>
2995
2972
  )}
2973
+ {exportCenter && <ExportBadge center={exportCenter} />}
2996
2974
  <button
2997
2975
  type="button"
2998
2976
  className="st-whatsnew"
@@ -7049,6 +7027,7 @@ function App() {
7049
7027
  // toggles on its own and coexists with Inspector/Changes/Comments/Chat.
7050
7028
  const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
7051
7029
  const whatsNew = useWhatsNew(MDCC_VERSION);
7030
+ const exportCenter = useExportCenter();
7052
7031
  // Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
7053
7032
  // "welcome" project on first launch; we ask it whether this is a first run and, if
7054
7033
  // so, show the wizard OVER the (empty) canvas browser. Completing any door switches
@@ -7696,6 +7675,10 @@ function App() {
7696
7675
  );
7697
7676
  } catch {}
7698
7677
  }
7678
+ } else if (m.type === 'export:job' && m.payload) {
7679
+ // feature-background-export-notification-center — full-snapshot
7680
+ // job state on every queued/running/progress/done/failed change.
7681
+ exportCenter.upsert(m.payload);
7699
7682
  } else if (m.type === 'sync:status' && m.payload) {
7700
7683
  // Phase 9 Task 8 — hub connection state for the offline banner.
7701
7684
  setSyncStatus(m.payload);
@@ -8854,7 +8837,12 @@ function App() {
8854
8837
  } catch {}
8855
8838
  };
8856
8839
  try {
8857
- const r = await fetch('/_api/export', {
8840
+ // feature-background-export-notification-center enqueue and reply
8841
+ // with the job id immediately; the notification center (which
8842
+ // already owns the WS connection) is the single place status/
8843
+ // progress/completion live from here, regardless of which dialog
8844
+ // created the job.
8845
+ const r = await fetch('/_api/export-jobs', {
8858
8846
  method: 'POST',
8859
8847
  headers: { 'content-type': 'application/json' },
8860
8848
  body: JSON.stringify(payload),
@@ -8863,28 +8851,8 @@ function App() {
8863
8851
  reply({ ok: false, error: (await r.text()) || String(r.status) });
8864
8852
  return;
8865
8853
  }
8866
- const disp = r.headers.get('Content-Disposition') || '';
8867
- const fn = /filename="([^"]+)"/.exec(disp);
8868
- const filename = (fn && fn[1]) || 'export';
8869
- const blob = await r.blob();
8870
- if (isNativeApp()) {
8871
- // Native: route the bridged in-canvas export through the OS save dialog
8872
- // too (WKWebView swallows the `<a download>` blob). Mirror the shell
8873
- // ExportDialog's doExport native branch.
8874
- const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
8875
- const savedPath = await saveExport(filename, bytes);
8876
- reply(savedPath ? { ok: true, filename } : { ok: false, error: 'Save cancelled' });
8877
- return;
8878
- }
8879
- const url = URL.createObjectURL(blob);
8880
- const a = document.createElement('a');
8881
- a.href = url;
8882
- a.download = filename;
8883
- document.body.appendChild(a);
8884
- a.click();
8885
- a.remove();
8886
- URL.revokeObjectURL(url);
8887
- reply({ ok: true, filename });
8854
+ const { jobId } = await r.json();
8855
+ reply({ ok: true, jobId });
8888
8856
  } catch (err) {
8889
8857
  reply({ ok: false, error: err && err.message ? err.message : String(err) });
8890
8858
  }
@@ -10011,6 +9979,7 @@ function App() {
10011
9979
  <UpdateBanner update={updateReady} onDismiss={() => setUpdateReady(null)} />
10012
9980
  <SyncBanner status={syncStatus} />
10013
9981
  {!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
9982
+ {!usageNudge && !tourSteps && <ExportToast center={exportCenter} />}
10014
9983
  {gitLifecycle && (
10015
9984
  <div role="status" aria-live="polite" className="st-banner st-banner--info">
10016
9985
  <span className="st-banner-dot" aria-hidden="true" />
@@ -10064,6 +10033,7 @@ function App() {
10064
10033
  onOpenReadiness={() => setReadinessOpen(true)}
10065
10034
  onOpenWhatsNew={whatsNew.openPanel}
10066
10035
  whatsNewCount={whatsNew.unseen.length}
10036
+ exportCenter={exportCenter}
10067
10037
  artboardCount={activeArtboards}
10068
10038
  inspectorOpen={inspectorOpen}
10069
10039
  inspectorTab={inspectorTab}
@@ -10730,6 +10700,7 @@ function App() {
10730
10700
  }}
10731
10701
  />
10732
10702
  <WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
10703
+ <ExportPanel center={exportCenter} />
10733
10704
  <ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
10734
10705
  {usageNudge && !tourSteps && !collabNudge && (
10735
10706
  <div className="mdcc-tour-nudge" role="status" aria-live="polite">