@markdstage/markdstage 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,8 +16,8 @@ exported PDF, or hybrid editable PowerPoint deck.
16
16
  ## Install
17
17
 
18
18
  ```console
19
- npx @markdstage/markdstage present slides.md
20
- npx @markdstage/markdstage preview slides.md
19
+ npx @markdstage/markdstage
20
+ npx @markdstage/markdstage slides.md
21
21
  npm install --global @markdstage/markdstage
22
22
  ```
23
23
 
@@ -31,6 +31,8 @@ npm install --global .\markdstage-markdstage-<version>.tgz
31
31
  ## Commands
32
32
 
33
33
  ```console
34
+ markdstage
35
+ markdstage slides.md
34
36
  markdstage present slides.md
35
37
  markdstage preview slides.md --watch
36
38
  markdstage validate slides.md --json
@@ -43,10 +45,14 @@ markdstage skill install --target codex
43
45
  markdstage skill install --target claude
44
46
  ```
45
47
 
48
+ `markdstage` opens an empty Canvas-equivalent UI for the current workspace.
49
+ Choose **Open Markdown** to load a deck. `markdstage slides.md` opens the same UI
50
+ in slide view with automatic refresh enabled.
51
+
46
52
  | Command | Description |
47
53
  | --- | --- |
48
- | `present` | Opens the presenter view with the current slide, next-slide preview, speaker notes, and navigation. Select **Start presentation** to open the synchronized audience-facing window; select **End presentation** to close it. `--watch` reloads on save, and `--no-open` serves the presenter URL without launching a browser. |
49
- | `preview` | Serves the deck on loopback and opens the MarkdStage preview: navigation, presenter view, next-slide preview, speaker notes, overview, custom themes, Mermaid, Architecture DSL, and local assets. `--watch` reloads on save while preserving the current slide, keeps the last valid deck when a save is broken, and enables Architecture editing. Without `--watch`, the source is read-only. `--no-open` serves the deck only. |
54
+ | `present` | Opens the full MarkdStage UI in presenter view. Open Markdown, automatic refresh, editing, export, and audience controls remain available. `--watch` starts in live mode, and `--no-open` serves the UI without launching a browser. |
55
+ | `preview` | Opens the same full UI in slide view. It is a compatibility/convenience entry point; `--watch` starts in live mode, and `--no-open` serves the UI without launching a browser. |
50
56
  | `validate` | Checks deck structure, Architecture DSL blocks, themes, and theme paths. |
51
57
  | `inspect` | Reports the same compact 1280x720 clipping diagnostics as the canvas `inspect_layout` action. `--slide <n>` limits it to one page, `--all` includes slides that fit, `--fail-on-issues` exits with code 5. |
52
58
  | `capture` | Writes 1280x720 PNG files. Without `--pages` only the slides reported as clipped are captured. |
@@ -55,19 +61,24 @@ markdstage skill install --target claude
55
61
  | `skill` | Installs or checks the portable Agent Skills for Codex (`.agents/skills/markdstage/`), Claude Code (`.claude/skills/markdstage/`), and GitHub Copilot (`.github/skills/markdstage/`). Locally modified files are never overwritten without `--force`. |
56
62
  | `help` | Shows the overview, or the help for one command. `markdstage help <command>` prints the same text as `markdstage <command> --help`. |
57
63
 
58
- Global options: `--workspace <dir>`, `--theme <name>`, `--theme-file <path>`,
59
- `--json`, `--help`, `--version`.
64
+ Application options: `--workspace <dir>`, `--theme <name>`,
65
+ `--theme-file <path>`, `--no-open`, and `--json`. Use `--help` and `--version`
66
+ for global information.
60
67
 
61
68
  ## Architecture editing
62
69
 
63
- Run `markdstage preview slides.md --watch` for the live authoring workflow. The
64
- browser starts in viewing mode. Select the pencil control to move Architecture
65
- elements; those placement changes are saved atomically to the matching
66
- `architecture` fence. Select **Advanced edit** to add, update, duplicate,
67
- reparent, or delete elements in the detailed designer, then select **Save**.
70
+ Run `markdstage slides.md` for the live authoring workflow. The browser starts
71
+ in the fixed 16:9 output preview and remains in viewing mode.
72
+ Select **Output preview** to switch to the retained responsive layout. Select
73
+ the pencil control to move Architecture elements; editing automatically switches
74
+ to the responsive layout, and those placement changes are saved atomically to
75
+ the matching `architecture` fence. Select **Advanced edit** to add, update,
76
+ duplicate, reparent, or delete elements in the detailed designer, then select
77
+ **Save**.
68
78
 
69
79
  The server rejects a save if the Markdown changed outside the editor. Successful
70
- saves reload the watched deck without changing the current slide. Presenter,
80
+ saves reload the watched deck without changing the current slide. Automatic
81
+ refresh can be toggled without disabling Architecture editing. Presenter,
71
82
  capture, inspect, and export views contain no editing UI.
72
83
 
73
84
  ```console
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markdstage/markdstage",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "Present, validate, inspect, capture, and export MarkdStage Markdown decks from the command line — no Copilot canvas required.",
5
5
  "license": "MIT",
6
6
  "author": "runceel",
package/shared/README.md CHANGED
@@ -108,12 +108,23 @@ The themed slide is displayed and updates automatically
108
108
  notes are converted from Markdown to readable plain text in the corresponding
109
109
  PowerPoint notes pane. AI may call `export_pptx` with another
110
110
  workspace-confined `.pptx` path.
111
- - Use **More controls > Output preview** to letterbox the current slide inside
112
- the canvas with
113
- the same fixed 1280×720 typography, spacing, diagram limits, and clipping used
114
- by PDF output. This preview is local to the canvas and does not change deck
115
- state. A visible and accessible warning identifies content that would be
116
- clipped.
111
+ - **Both export buttons show a visible notification outside More controls.**
112
+ Progress remains visible while saving, and both buttons are disabled until
113
+ the operation finishes. Export buttons return to their normal appearance and
114
+ tooltips after success or failure; the notification carries the result.
115
+ Success appears only after the file has been saved,
116
+ with its file name and selectable save location. It dismisses after eight
117
+ seconds, pausing while hovered or keyboard-focused. Errors remain until
118
+ dismissed or another export starts. Success and error notifications also have
119
+ a dismiss button and are announced to screen readers. Notifications never
120
+ appear in exported files or the external presentation window.
121
+ - The canvas starts in **More controls > Output preview**, letterboxing the
122
+ current slide with the same fixed 1280×720 typography, spacing, diagram
123
+ limits, and clipping used by PDF output. Select **Output preview** to return
124
+ to the retained responsive canvas layout. This preview is local to the canvas
125
+ and does not change deck state. A visible and accessible warning identifies
126
+ content that would be clipped. Entering lightweight Architecture editing also
127
+ returns to the responsive layout so its placement controls remain available.
117
128
  - AI should call **`inspect_layout` before exporting a non-scrolling deck**. It
118
129
  renders the currently registered in-memory output snapshot in headless
119
130
  Chromium; it does not read or validate the source file named by `sourceName`.
@@ -1038,7 +1049,7 @@ fails with `invalid_input`; pass the complete `slides` array or call
1038
1049
  | `inspect_layout` | `{ index?: number, includeFits?: boolean }`. Render the registered in-memory PDF snapshot with the fixed 1280×720 output layout; this does not inspect the source file on disk. Omit `index` for one preferred whole-deck inspection. Serialize targeted calls because PDF, layout, and PNG jobs are exclusive. By default, return only clipped pages; `includeFits` includes successful pages. Returns dimensions, issue counts, overflow measurements, nested scroll containers, and a bounded list of element hints. Requires Edge, Chrome, or Chromium. |
1039
1050
  | `capture_slides` | `{ indexes?: number[], outputDirectory?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Generate PDF-equivalent 1280×720 PNGs for at most 10 zero-based indexes. When `indexes` is omitted, inspect the deck and capture only clipped pages. Paths stay inside the workspace; results contain paths and layout summaries, not image bytes. Requires Edge, Chrome, or Chromium. |
1040
1051
  | `export_pdf` | `{ outputPath?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Export one 16:9 page per slide. Relative paths use workspace root; default is `markdstage.pdf`. Theme affects PDF only. Reject paths outside workspace and non-`.pdf` files. Temporary slide replacement and the automatic back cover are included. Returns `{ ok, path, total, theme, bytes }`. Requires Edge, Chrome, or Chromium. |
1041
- | `export_pptx` | `{ outputPath?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Export a hybrid editable 16:9 PowerPoint deck. Supported text, lists, links, tables, fenced code blocks, raster images, and Architecture DSL objects remain native. Code blocks preserve syntax-colored editable runs, whitespace, monospace typography, and block decoration; unsupported effects such as shadows remain reported background fallbacks. Speaker-note Markdown is exported as readable plain text in the corresponding PowerPoint notes pane. Architecture nodes, groups, and connector-label pills are visible AutoShapes with integrated text; icons are foreground pictures. Mermaid and unsupported visuals become reported background fallbacks. Relative paths use workspace root; default is `markdstage.pptx`. Reject paths outside workspace and non-`.pptx` files. Temporary slide replacement and the automatic back cover are included. Returns `{ ok, path, total, theme, bytes, format, fallbackCount, fallbacks }`. Requires Edge, Chrome, or Chromium. |
1052
+ | `export_pptx` | `{ outputPath?: string, theme?: "dark" | "light" | "microsoft" | "custom" }`. Export a hybrid editable 16:9 PowerPoint deck. Supported text, lists, links, tables, fenced code blocks, raster images, Mermaid diagrams, and Architecture DSL objects remain native. Code blocks preserve syntax-colored editable runs, whitespace, monospace typography, and block decoration; unsupported effects such as shadows remain reported background fallbacks. Speaker-note Markdown is exported as readable plain text in the corresponding PowerPoint notes pane. Architecture nodes, groups, and connector-label pills are visible AutoShapes with integrated text; icons are foreground pictures. Mermaid diagrams export as editable shapes, text, and connectors with unsupported SVG details preserved as per-element fallback pictures. Unsupported visuals become reported background fallbacks. Relative paths use workspace root; default is `markdstage.pptx`. Reject paths outside workspace and non-`.pptx` files. Temporary slide replacement and the automatic back cover are included. Returns `{ ok, path, total, theme, bytes, format, fallbackCount, fallbacks }`. Requires Edge, Chrome, or Chromium. |
1042
1053
  | `edit_architecture` | `{ enabled: boolean }`. Toggle placement editing. Imported decks also write to the source fence; direct decks write to canvas state. Presenter/print omit UI. Mode is not persisted and `reset` disables it. Returns `{ ok, enabled, version }`. |
1043
1054
  | `reset` | No input. Clear deck/slide state, disable editing, and return to the waiting view. |
1044
1055
 
@@ -0,0 +1,312 @@
1
+ import {
2
+ createScene,
3
+ normalizeScene,
4
+ validateScene,
5
+ } from "./scene-graph.mjs";
6
+
7
+ const DEFAULT_SOURCE_PATH = "architecture";
8
+
9
+ function roundedMetric(value) {
10
+ return Math.round(Math.max(0, Number(value) || 0) * 10) / 10;
11
+ }
12
+
13
+ function finiteNumberOr(value, fallback) {
14
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
15
+ }
16
+
17
+ function nonEmptyStringOr(value, fallback) {
18
+ return typeof value === "string" && value ? value : fallback;
19
+ }
20
+
21
+ function transformBounds(bounds, options) {
22
+ return {
23
+ x: roundedMetric(options.originX + finiteNumberOr(bounds?.x, 0) * options.scale),
24
+ y: roundedMetric(options.originY + finiteNumberOr(bounds?.y, 0) * options.scale),
25
+ width: roundedMetric(finiteNumberOr(bounds?.width, 0) * options.scale),
26
+ height: roundedMetric(finiteNumberOr(bounds?.height, 0) * options.scale),
27
+ };
28
+ }
29
+
30
+ function transformPoint(point, options) {
31
+ return {
32
+ x: roundedMetric(options.originX + finiteNumberOr(point?.x, 0) * options.scale),
33
+ y: roundedMetric(options.originY + finiteNumberOr(point?.y, 0) * options.scale),
34
+ };
35
+ }
36
+
37
+ function scaledMetric(value, options) {
38
+ return value === undefined ? undefined : roundedMetric(finiteNumberOr(value, 0) * options.scale);
39
+ }
40
+
41
+ function isSceneColor(value) {
42
+ if (value === null || value === undefined) return true;
43
+ if (typeof value !== "string") return false;
44
+ const text = value.trim();
45
+ if (/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(text)) return true;
46
+ return /^rgba?\((.*)\)$/i.test(text);
47
+ }
48
+
49
+ function normalizeColor(value, path, options, diagnostics) {
50
+ if (value === undefined) return undefined;
51
+ const resolved = options.resolveColor(value, path);
52
+ if (resolved === undefined) return undefined;
53
+ if (resolved === null || resolved === "" || resolved === "none" || resolved === "transparent") return null;
54
+ if (isSceneColor(resolved)) return resolved;
55
+ diagnostics.push({
56
+ path,
57
+ kind: "color",
58
+ reason: `color is not resolved for scene graph: ${String(value)}`,
59
+ });
60
+ return null;
61
+ }
62
+
63
+ function definedEntries(object) {
64
+ return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
65
+ }
66
+
67
+ function mapStyle(object, path, options, diagnostics) {
68
+ const dash = object.dash === undefined ? undefined : options.resolveDash(object.dash, path);
69
+ const sceneDash = (() => {
70
+ if (dash === undefined) return undefined;
71
+ if (dash === "" || dash === "solid" || dash === "dash" || dash === "dashDot" || dash === "dot") return dash;
72
+ if (dash === "dotted") return "dot";
73
+ const values = String(dash)
74
+ .trim()
75
+ .split(/[ ,]+/)
76
+ .map(Number)
77
+ .filter(Number.isFinite);
78
+ if (values.length >= 2 && values[0] <= 2 && values[1] >= values[0] * 2) return "dot";
79
+ if (values.length >= 2) return "dash";
80
+ return "solid";
81
+ })();
82
+ return definedEntries({
83
+ fill: normalizeColor(object.fill, `${path}.fill`, options, diagnostics),
84
+ stroke: normalizeColor(object.stroke, `${path}.stroke`, options, diagnostics),
85
+ strokeWidth: scaledMetric(object.strokeWidth, options),
86
+ dash: sceneDash,
87
+ opacity: object.opacity,
88
+ cornerRadius: scaledMetric(object.cornerRadius, options),
89
+ });
90
+ }
91
+
92
+ function mapText(text, path, options, diagnostics) {
93
+ if (!text?.paragraphs) return text;
94
+ return {
95
+ paragraphs: text.paragraphs.map((paragraph, paragraphIndex) => ({
96
+ ...paragraph,
97
+ runs: paragraph.runs.map((run, runIndex) => {
98
+ const runPath = `${path}.paragraphs[${paragraphIndex}].runs[${runIndex}]`;
99
+ return definedEntries({
100
+ ...run,
101
+ fontFace: options.fontFace || run.fontFace,
102
+ fontSize: scaledMetric(run.fontSize, options),
103
+ bold: Number(run.fontWeight) >= 600,
104
+ color: normalizeColor(run.color, `${runPath}.color`, options, diagnostics),
105
+ });
106
+ }),
107
+ })),
108
+ };
109
+ }
110
+
111
+ function mapTextLayout(object, options) {
112
+ return definedEntries({
113
+ alignment: object.alignment,
114
+ verticalAlignment: object.verticalAlignment,
115
+ textWrap: object.textWrap,
116
+ textInsets: object.textInsets
117
+ ? Object.fromEntries(
118
+ Object.entries(object.textInsets).map(([key, value]) => [key, scaledMetric(value, options)]),
119
+ )
120
+ : undefined,
121
+ });
122
+ }
123
+
124
+ function applyShapeOpacityToText(text, opacity) {
125
+ if (!text?.paragraphs || !Number.isFinite(opacity)) return text;
126
+ return {
127
+ ...text,
128
+ paragraphs: text.paragraphs.map((paragraph) => ({
129
+ ...paragraph,
130
+ runs: paragraph.runs.map((run) => ({
131
+ ...run,
132
+ opacity: (Number.isFinite(run.opacity) ? run.opacity : 1) * opacity,
133
+ })),
134
+ })),
135
+ };
136
+ }
137
+
138
+ function sourcePathFor(object, fallback) {
139
+ return nonEmptyStringOr(object.architecture?.sourcePath, nonEmptyStringOr(object.sourcePath, fallback));
140
+ }
141
+
142
+ function sourceIdFor(object) {
143
+ return nonEmptyStringOr(object.architecture?.id, nonEmptyStringOr(object.id, ""));
144
+ }
145
+
146
+ function shapePreset(object) {
147
+ return nonEmptyStringOr(object.shape, "rect");
148
+ }
149
+
150
+ function arrowEndFor(value) {
151
+ if (value === true) return "triangle";
152
+ if (value === false || value === undefined || value === null) return "none";
153
+ return value;
154
+ }
155
+
156
+ function imageResult(entry, kind, options) {
157
+ const resolved = options.resolveImage(entry, kind);
158
+ if (typeof resolved === "string") return { src: resolved };
159
+ if (resolved && typeof resolved === "object") return resolved;
160
+ return { src: "" };
161
+ }
162
+
163
+ function imageNode(entry, kind, index, options) {
164
+ const image = imageResult(entry, kind, options);
165
+ const bounds = image.bounds || transformBounds(entry, options);
166
+ const architecture = definedEntries({
167
+ ...(entry.architecture || {}),
168
+ kind,
169
+ id: entry.architecture?.id || entry.id || "",
170
+ sourcePath: entry.architecture?.sourcePath || entry.sourcePath,
171
+ order: entry.architecture?.order ?? entry.order,
172
+ z: entry.architecture?.z ?? entry.z,
173
+ });
174
+ if (!image.src) {
175
+ return {
176
+ kind: "fallback",
177
+ ...(sourceIdFor({ ...entry, architecture }) ? { id: sourceIdFor({ ...entry, architecture }) } : {}),
178
+ sourcePath: sourcePathFor({ ...entry, architecture }, `${kind}s[${index}]`),
179
+ z: finiteNumberOr(image.z, index),
180
+ bounds,
181
+ reason: `${kind}-image-unavailable`,
182
+ meta: { architecture },
183
+ };
184
+ }
185
+ return {
186
+ kind: "image",
187
+ ...(sourceIdFor({ ...entry, architecture }) ? { id: sourceIdFor({ ...entry, architecture }) } : {}),
188
+ sourcePath: sourcePathFor({ ...entry, architecture }, `${kind}s[${index}]`),
189
+ z: finiteNumberOr(image.z, index),
190
+ bounds,
191
+ src: image.src,
192
+ alt: nonEmptyStringOr(image.alt, kind === "icon-picture" ? `${entry.icon} icon` : `${architecture.id} image`),
193
+ fit: image.fit || "fill",
194
+ opacity: image.opacity ?? 1,
195
+ meta: { architecture },
196
+ };
197
+ }
198
+
199
+ function objectNode(object, index, options, diagnostics) {
200
+ const path = `objects[${index}]`;
201
+ if (object.type === "connector") {
202
+ return {
203
+ kind: "connector",
204
+ ...(sourceIdFor(object) ? { id: sourceIdFor(object) } : {}),
205
+ sourcePath: sourcePathFor(object, path),
206
+ z: index,
207
+ points: (object.points || []).map((point) => transformPoint(point, options)),
208
+ style: mapStyle(object, path, options, diagnostics),
209
+ arrowStart: "none",
210
+ arrowEnd: arrowEndFor(object.arrowEnd),
211
+ meta: definedEntries({
212
+ architecture: object.architecture,
213
+ from: object.from,
214
+ to: object.to,
215
+ }),
216
+ };
217
+ }
218
+ if (object.type === "image") {
219
+ return imageNode(object, "image-picture", index, options);
220
+ }
221
+ if (object.type === "shape") {
222
+ const text = object.text === undefined
223
+ ? undefined
224
+ : applyShapeOpacityToText(mapText(object.text, `${path}.text`, options, diagnostics), object.opacity);
225
+ return {
226
+ kind: "shape",
227
+ ...(sourceIdFor(object) ? { id: sourceIdFor(object) } : {}),
228
+ sourcePath: sourcePathFor(object, path),
229
+ z: index,
230
+ bounds: transformBounds(object, options),
231
+ preset: shapePreset(object),
232
+ style: mapStyle(object, path, options, diagnostics),
233
+ ...(text !== undefined ? { text } : {}),
234
+ textLayout: mapTextLayout(object, options),
235
+ meta: definedEntries({
236
+ architecture: object.architecture,
237
+ icon: object.icon,
238
+ }),
239
+ };
240
+ }
241
+ return {
242
+ kind: "fallback",
243
+ sourcePath: sourcePathFor(object, path),
244
+ z: index,
245
+ bounds: transformBounds(object, options),
246
+ reason: `unsupported architecture object type: ${String(object.type)}`,
247
+ meta: definedEntries({ architecture: object.architecture }),
248
+ };
249
+ }
250
+
251
+ function fallbackNode(fallback, index, objectCount, options) {
252
+ return {
253
+ kind: "fallback",
254
+ sourcePath: nonEmptyStringOr(fallback.sourcePath, nonEmptyStringOr(fallback.path, `fallbacks[${index}]`)),
255
+ z: objectCount + index,
256
+ bounds: transformBounds(fallback, options),
257
+ reason: nonEmptyStringOr(fallback.reason, "architecture-rendered-as-artwork"),
258
+ };
259
+ }
260
+
261
+ function normalizeOptions(options = {}) {
262
+ return {
263
+ path: nonEmptyStringOr(options.path, DEFAULT_SOURCE_PATH),
264
+ resolveColor: typeof options.resolveColor === "function" ? options.resolveColor : (value) => value,
265
+ resolveImage: typeof options.resolveImage === "function" ? options.resolveImage : (entry) => entry?.src || "",
266
+ resolveDash: typeof options.resolveDash === "function" ? options.resolveDash : (value) => value,
267
+ scale: finiteNumberOr(options.scale, 1),
268
+ originX: finiteNumberOr(options.originX, 0),
269
+ originY: finiteNumberOr(options.originY, 0),
270
+ fontFace: typeof options.fontFace === "string" && options.fontFace ? options.fontFace : undefined,
271
+ };
272
+ }
273
+
274
+ export function architectureSnapshotToScene(snapshot, options = {}) {
275
+ const normalizedOptions = normalizeOptions(options);
276
+ const diagnostics = [];
277
+ const objects = Array.isArray(snapshot?.objects) ? snapshot.objects : [];
278
+ const icons = Array.isArray(snapshot?.icons) ? snapshot.icons : [];
279
+ const fallbacks = Array.isArray(snapshot?.fallbacks) ? snapshot.fallbacks : [];
280
+ const nodes = objects.flatMap((object, index) => {
281
+ const mapped = [objectNode(object, index, normalizedOptions, diagnostics)];
282
+ if (object.type === "shape" && object.icon) {
283
+ const icon = icons.find((candidate) => candidate.id === object.architecture?.id);
284
+ if (icon) mapped.push(imageNode(icon, "icon-picture", index + 1 / 2, normalizedOptions));
285
+ }
286
+ return mapped;
287
+ });
288
+ const iconObjectIds = new Set(
289
+ objects
290
+ .filter((object) => object.type === "shape" && object.icon)
291
+ .map((object) => object.architecture?.id),
292
+ );
293
+ icons
294
+ .filter((icon) => !iconObjectIds.has(icon.id))
295
+ .forEach((icon, index) => nodes.push(imageNode(icon, "icon-picture", objects.length + index, normalizedOptions)));
296
+ fallbacks.forEach((fallback, index) => nodes.push(fallbackNode(fallback, index, nodes.length, normalizedOptions)));
297
+
298
+ const accessibility = definedEntries({
299
+ title: snapshot?.title,
300
+ description: snapshot?.description,
301
+ });
302
+ const result = normalizeScene(createScene({
303
+ width: finiteNumberOr(snapshot?.canvas?.width, 0) * normalizedOptions.scale,
304
+ height: finiteNumberOr(snapshot?.canvas?.height, 0) * normalizedOptions.scale,
305
+ source: { kind: "architecture", path: normalizedOptions.path },
306
+ ...(Object.keys(accessibility).length ? { accessibility } : {}),
307
+ nodes,
308
+ }));
309
+ result.diagnostics.unshift(...diagnostics);
310
+ validateScene(result.scene);
311
+ return result;
312
+ }
@@ -1,4 +1,6 @@
1
1
  import { architectureContract } from "./architecture-contract.mjs";
2
+ import { architectureSnapshotToScene } from "./architecture-scene.mjs";
3
+ import { sceneToSvg, svgPrimitive } from "./scene-svg.mjs";
2
4
  import {
3
5
  ArchitectureError,
4
6
  architectureCompatibilityWarnings,
@@ -11,7 +13,6 @@ import {
11
13
  unknownArchitectureField,
12
14
  } from "./architecture-diagnostics.mjs";
13
15
 
14
- const SVG_NS = "http://www.w3.org/2000/svg";
15
16
  const DEFAULT_CANVAS = Object.freeze({ width: 1600, height: 900 });
16
17
  const DSL_VERSION = architectureContract.root.properties.version.const;
17
18
  const EMPTY_ARCHITECTURE_SOURCE = '{\n "version": 1,\n "elements": []\n}\n';
@@ -1307,11 +1308,8 @@ export function parseArchitecture(source) {
1307
1308
  }
1308
1309
 
1309
1310
  function svgElement(documentRef, tag, attributes = {}) {
1310
- const element = documentRef.createElementNS(SVG_NS, tag);
1311
- for (const [name, value] of Object.entries(attributes)) {
1312
- if (value !== undefined && value !== "") element.setAttribute(name, String(value));
1313
- }
1314
- return element;
1311
+ // Produce portable visual detail; scene-svg is the only SVG DOM writer.
1312
+ return svgPrimitive(tag, attributes);
1315
1313
  }
1316
1314
 
1317
1315
  function appendSvgTitle(documentRef, parent, text) {
@@ -3895,13 +3893,13 @@ export function architectureSemanticSnapshot(model) {
3895
3893
  * export. Coordinates remain in Architecture canvas units here; renderer.js
3896
3894
  * maps them through the rendered SVG viewBox into slide pixels.
3897
3895
  */
3898
- export function architecturePowerPointSnapshot(model, documentRef = globalThis.document) {
3896
+ export function architecturePowerPointSnapshot(model, documentRef = globalThis.document, routingPlan) {
3899
3897
  const lookup = new Map(
3900
3898
  model.elements
3901
3899
  .filter((element) => element.type !== "connector")
3902
3900
  .map((element) => [element.id, element]),
3903
3901
  );
3904
- const { routes, diagnostics } = planConnectorRoutes(model, lookup);
3902
+ const { routes, diagnostics } = routingPlan || planConnectorRoutes(model, lookup);
3905
3903
  const objects = [];
3906
3904
  const fallbacks = [];
3907
3905
  const icons = [];
@@ -4152,8 +4150,8 @@ export function renderArchitectureDiagram(
4152
4150
  .filter((element) => element.type !== "connector")
4153
4151
  .map((element) => [element.id, element]),
4154
4152
  );
4155
- const { routes: connectorRoutes, diagnostics: routingDiagnostics } =
4156
- planConnectorRoutes(model, lookup);
4153
+ const routingPlan = planConnectorRoutes(model, lookup);
4154
+ const { routes: connectorRoutes, diagnostics: routingDiagnostics } = routingPlan;
4157
4155
  model.elements.forEach((element, index) => {
4158
4156
  if (element.type === "image") {
4159
4157
  const clipPath = svgElement(documentRef, "clipPath", {
@@ -4195,16 +4193,21 @@ export function renderArchitectureDiagram(
4195
4193
  // Table for announcing connector endpoints by visible label; traverse elements only once.
4196
4194
  const endpointNames = endpointDisplayNames(model.elements);
4197
4195
  const frontLabels = [];
4196
+ const visuals = new Map();
4198
4197
 
4199
4198
  model.elements.forEach((element, index) => {
4200
4199
  if (element.type === "group") {
4201
- svg.appendChild(renderGroup(documentRef, element));
4200
+ const primitive = renderGroup(documentRef, element);
4201
+ visuals.set(`group:${element.order}`, primitive);
4202
+ svg.appendChild(primitive);
4202
4203
  } else if (element.type === "node") {
4203
- svg.appendChild(renderNode(documentRef, element));
4204
+ const primitive = renderNode(documentRef, element);
4205
+ visuals.set(`node:${element.order}`, primitive);
4206
+ svg.appendChild(primitive);
4204
4207
  } else if (element.type === "image") {
4205
- svg.appendChild(
4206
- renderImage(documentRef, element, `architecture-image-clip-${renderId}-${index}`),
4207
- );
4208
+ const primitive = renderImage(documentRef, element, `architecture-image-clip-${renderId}-${index}`);
4209
+ visuals.set(`image-picture:${element.order}`, primitive);
4210
+ svg.appendChild(primitive);
4208
4211
  } else {
4209
4212
  const rendered = renderConnector(
4210
4213
  documentRef,
@@ -4214,13 +4217,33 @@ export function renderArchitectureDiagram(
4214
4217
  endpointNames,
4215
4218
  model.canvas,
4216
4219
  );
4220
+ visuals.set(`connector:${element.order}`, rendered.group);
4217
4221
  svg.appendChild(rendered.group);
4218
- if (rendered.frontLabel) frontLabels.push(rendered.frontLabel);
4222
+ if (rendered.frontLabel) {
4223
+ visuals.set(`connector-label:${element.order}`, rendered.frontLabel);
4224
+ frontLabels.push(rendered.frontLabel);
4225
+ }
4219
4226
  }
4220
4227
  });
4221
4228
  frontLabels.forEach((label) => svg.appendChild(label));
4222
- wrapper.appendChild(svg);
4223
- const powerPointSnapshot = architecturePowerPointSnapshot(model, documentRef);
4229
+ const powerPointSnapshot = architecturePowerPointSnapshot(model, documentRef, routingPlan);
4230
+ const { scene } = architectureSnapshotToScene(powerPointSnapshot);
4231
+ const slots = new Map();
4232
+ scene.nodes.forEach((node, index) => {
4233
+ const source = node.meta?.architecture;
4234
+ const visual = visuals.get(`${source?.kind}:${source?.order}`);
4235
+ if (visual) {
4236
+ node.meta.svg = visual;
4237
+ slots.set(visual, index);
4238
+ } else {
4239
+ // Icons and back-layer labels are painted within their owning node/connector
4240
+ // so opacity, accessible names and editor hit targets remain a single subtree.
4241
+ node.meta = { ...node.meta, svgOwner: source?.order ?? index };
4242
+ }
4243
+ });
4244
+ svg.children = svg.children.map((child) => slots.has(child) ? { sceneNode: slots.get(child) } : child);
4245
+ scene.meta = { svgRoot: svg };
4246
+ wrapper.appendChild(sceneToSvg(scene, { document: documentRef, template: svg }));
4224
4247
  Object.defineProperty(wrapper, "__presentationPptxSnapshot", {
4225
4248
  value: powerPointSnapshot,
4226
4249
  enumerable: true,
@@ -72,9 +72,17 @@
72
72
  </section>
73
73
  </div>
74
74
  <span id="presentStatus" class="sr-only" aria-live="polite"></span>
75
- <span id="exportStatus" class="sr-only" aria-live="polite"></span>
76
75
  <span id="sourceStatus" class="sr-only" aria-live="polite"></span>
77
76
  </nav>
77
+ <span id="exportStatus" class="export-feedback sr-only" role="status" aria-live="polite" aria-atomic="true"></span>
78
+ <span id="exportErrorStatus" class="export-feedback sr-only" role="alert" aria-atomic="true"></span>
79
+ <section id="exportNotification" class="export-feedback export-notification" aria-label="Export notification" hidden>
80
+ <div class="export-notification-content">
81
+ <p id="exportNotificationMessage" class="export-notification-message"></p>
82
+ <p id="exportNotificationPath" class="export-notification-path" tabindex="0" hidden></p>
83
+ </div>
84
+ <button id="exportNotificationClose" class="nav-btn" type="button" aria-label="Dismiss export notification" hidden>&#10005;</button>
85
+ </section>
78
86
  <div id="layoutWarning" class="layout-warning" role="status" aria-live="polite" hidden></div>
79
87
 
80
88
  <section id="presenterView" class="presenter-view" hidden aria-label="Presenter view">