You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Build the frontend, click the camera icon when in Workflows. An empty workflow will render a small grid to the PNG, while a real workflow will render the entire workflow.
1. Medium - a non-settling toBlob permanently disables the export button and leaks a full workflow DOM clone into the live document
invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:382-391 awaits toBlob with no timeout and no abort. invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx:62-74 relies entirely on that promise settling: .finally(() => setIsExportingWorkflow(false)).
Chain:
html-to-image@1.11.13createImage is r.onload = function(){ r.decode().then(...) } with no rejection handler on decode(). If decode() rejects (the realistic trigger is an oversized serialized SVG from a large workflow), the promise it returns never resolves and never rejects.
exportWorkflowAsPng's try/finally never reaches finally, so stagingWrapper.remove() never runs. A complete second copy of #workflow-editor stays appended to flowElement.parentElement for the rest of the session.
.catch(...) and .finally(...) in ViewportControls never run, so isExportingWorkflow stays true and the camera IconButton stays isDisabled + isLoading (ViewportControls.tsx:101-102) until the editor remounts. No toast, no retry.
To expose this issue, add a test that stubs toBlob with a promise that never settles, races exportWorkflowAsPng against a deadline, and asserts the call rejects and that the staging wrapper has been removed. That test can only pass once a timeout is added around toBlob.
2. Medium - one un-embeddable image anywhere in the workflow aborts the whole export
getWorkflowExportOptions (invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:178-187) sets neither imagePlaceholder nor onImageErrorHandler.
Chain:
html-to-image walks every node and calls embedImageNode on each HTMLImageElement / SVGImageElement.
Its resourceToDataURL catches the fetch failure, console.warns, and returns options.imagePlaceholder || ''.
embedImageNode then does img.srcset = ''; img.src = '' and awaits onload/onerror. Empty src fires error, and because onImageErrorHandler is unset, onerror is the raw reject.
The rejection propagates out of toBlob, so the entire PNG export fails.
Workflows routinely carry image references (invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx, invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx). A single deleted or 404 image makes the camera button produce only nodes.downloadWorkflowImageError with no indication of the cause. html-to-image also caches the empty result module-globally, so a transient failure poisons every later export in the session.
To expose this issue, add a test that asserts getWorkflowExportOptions returns an imagePlaceholder (or onImageErrorHandler) so a failed image degrades to a placeholder instead of failing the export.
3. Medium - skipFonts: true renders the PNG in a fallback typeface
Invoke ships a webfont: import '@fontsource-variable/inter' at invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx:1. html-to-image serializes the clone into an SVG foreignObject encoded as a data:image/svg+xml URL and loads it through an <img>. SVG in <img> is secure static mode: no external resource loading. The only mechanism that would make Inter available is html-to-image's @font-face inlining, and skipFonts: true disables exactly that. The fontsource CSS is same-origin and bundled by Vite, so cssRules is readable and the inlining path would in fact succeed here.
font-family is copied (line 69), so the PNG asks for Inter Variable and gets the browser default instead. Different metrics also shift every label width, compounding finding 4. This contradicts the PR's "Invoke color scheme ... preserved" framing, which holds for color but not for type.
This is primarily a rendered-text concern and there is no approved DOM testing framework in this repo (no jsdom/happy-dom in invokeai/frontend/web/package.json), so it needs manual verification: export a workflow and compare the label typeface against the editor.
4. Medium - forced one-line field titles can be clipped at the image edge or overlap neighbouring nodes
setWorkflowExportInputFieldTitleStyles (invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:278-285) forces white-space: nowrap, overflow: visible, text-overflow: clip on every [data-node-input-field-title="true"].
Chain:
The node body has no clipping: invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNode.tsx:24-37 sets no overflow: hidden, and invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx:66-79 sets only borderRadius.
So a label wider than the node renders past the node's right edge onto the canvas.
getWorkflowContentBounds (workflowImageExport.ts:129-158) derives the canvas from getNodesBounds plus .react-flow__edge-pathgetBBox() only. Rendered text extents are never measured.
For a right-most node, any overflow beyond EXPORT_PADDING (100, workflowImageExport.ts:4) is cut off at the image boundary; elsewhere it silently overlaps whatever is to the right.
This defeats the PR's stated goal ("Input labels remain one line with full text") in exactly the case the override exists for - labels too long to fit.
To expose this issue, add a test that exports getWorkflowContentBounds and asserts it widens the returned rect to cover a measured overflowing label, not only node bounds and edge bounding boxes.
5. Low - the includeStyleProperties allowlist silently drops flex-wrap and direction
EXPORT_STYLE_PROPERTIES (invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:6-98) replaces html-to-image's default "all computed properties" with a fixed 90-entry list. Anything absent is dropped from the capture.
Two confirmed consumers:
flex-wrap: invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx:146 renders <Flex alignItems="center" gap={1} flexWrap="wrap"> with a workflow name plus up to three badges inside a node body. In the export this collapses to nowrap and the badges are squashed or overflow.
direction: invokeai/frontend/web/src/app/hooks/useSyncLangDirection.ts:34 sets document.body.dir. RTL is a shipped feature (invokeai/frontend/web/public/locales/ar.json, invokeai/frontend/web/public/locales/he.json). The clone is serialized into a standalone foreignObject where that ancestor dir no longer applies, and direction is not in the list, so ar/he users get an LTR-laid-out PNG.
To expose this issue, add a test that asserts EXPORT_STYLE_PROPERTIES contains direction and flex-wrap.
6. Low - the grid geometry is duplicated from Flow.tsx with nothing binding the two
GRID_GAP = 25 at invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:112 duplicates const snapGrid: [number, number] = [25, 25] at invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx:91, which is what <Background gap={snapGrid} offset={snapGrid} /> (Flow.tsx:525) actually uses. setBackgroundGridForExport (workflowImageExport.ts:241-244) additionally hardcodes cx/cy/r = 0.5, which is only correct because <Background> is left at its default size={1} and the export pins zoom to 1.
Changing snapGrid, or passing size/color to <Background>, desyncs the exported grid from the editor with no failing test and no compile error.
To expose this issue, add a test that imports snapGrid from the flow module (exporting it if necessary) and asserts it equals the export module's grid gap.
7. Low - the export error is discarded
invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx:73 is .catch(handleWorkflowImageExportError), and the handler at lines 47-49 takes no arguments. The thrown error is never logged. Combined with findings 1 and 2, a field failure produces one generic toast string and nothing actionable.
8. Low - the export puts a duplicate id="workflow-editor" (and every other id in the subtree) into the live document
workflowImageExport.ts:374 does flowElement.cloneNode(true), which copies ids, and line 380 appends it to flowElement.parentElement. flowElement is #workflow-editor itself (Flow.tsx:490; @xyflow/react applies the id prop to the wrapper div that also carries .react-flow).
This is currently benign: the only other consumer, invokeai/frontend/web/src/features/nodes/hooks/useBuildNode.ts:26, uses document.querySelector, which returns the earlier original. But it is invalid HTML for the duration of the export, duplicates every Chakra-generated aria-labelledby/aria-describedby target and every react-flow SVG marker id, and breaks the moment anyone reaches for getElementById or a nth-match query. The pattern already applied to the background pattern id (workflowImageExport.ts:230) is not applied to the root or to markers.
9. Low - the added tests do not test the export
invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts - 11 tests, all passing (verified, see Verification), but none of them can fail for a real defect in this feature:
getWorkflowContentBounds (workflowImageExport.ts:129), which decides what ends up cut off, is module-private and untested. So are prepareExportClone, setBackgroundGridForExport, inlineSvgStylesForExport, downloadPng, and exportWorkflowAsPng.
workflowImageExport.test.ts:66-70 ('preserves single-line field title styles in the export clone') asserts only that a literal array declared in the same module contains three strings. It touches no clone. It is also self-contradictory: setWorkflowExportInputFieldTitleStyles forces display: block, which makes the -webkit-line-clamp it asserts on inert.
The DOM helper tests (lines 87-138) stub querySelectorAll with a hand-rolled function that returns the element only for the exact selector string the implementation passes. A selector that stops matching real markup - for example if NodeWrapper gains a wrapper element and .react-flow__node > [data-is-selected] no longer matches - still passes.
sanitizeWorkflowImageFilename has two cases; there is no coverage for length capping.
The suite runs in vitest's default node environment (no jsdom/happy-dom in invokeai/frontend/web/package.json), so real-DOM assertions are not available today.
To expose the selector risk, add a test that asserts the exact selector strings the helpers query, pinned against the data-* attributes added in this PR, so removing data-node-status-indicator / data-node-info-icon / data-node-input-field-title from the components fails a test rather than silently degrading the image.
1. Medium-High: aspect-ratio is missing from the style allowlist, so the Current Image node collapses in the exported PNG
invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:9-103 (EXPORT_STYLE_PROPERTIES), consumed at invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:216.
html-to-image serializes the clone into an SVG foreignObject data URL, which carries none of the app stylesheets, so every visual property must be inlined from computed style. In html-to-image@1.11.13lib/clone-node.js:164-191, cloneCSSStyle only takes the sourceStyle.cssText fast path when computed cssText is non-empty. I measured this in Chrome: getComputedStyle(el).cssText === '' (length 0). So the getStyleProperties(options) branch always runs, and the allowlist is the complete styling contract - anything not listed is dropped.
aspect-ratio is not in EXPORT_STYLE_PROPERTIES.
invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx:74 sets aspectRatio="1/1" on the Flex that gives the Current Image node its entire body height (parent is NonInvocationNodeWrapper with width={384}; there is no explicit height). invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldCollectionInputComponent.tsx:156 does the same for collection thumbnails.
Measured in Chrome: a width:100px; aspect-ratio:1/1; display:flex; flex-direction:column box with a height:100% child is 100px tall; removing only aspect-ratio makes it 0px tall.
Trigger: export any workflow containing a Current Image node, or a node with an image-collection input. The node renders collapsed in the PNG, while getNodesBounds reserved its full on-screen size and the edge paths were baked from live coordinates - so the node is both wrong-sized and detached from its connectors.
To expose this issue, add a test that asserts EXPORT_STYLE_PROPERTIES contains every CSS property the workflow node tree depends on for layout, seeded with aspect-ratio (the same shape as the existing invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts:68-72 assertion, which already guards text-overflow / -webkit-line-clamp / -webkit-box-orient).
2. Medium: input-label overflow is measured against the pre-export layout, so wide labels are clipped by the canvas edge
Measurement at invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:171-186; the conflicting mutation at invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:311-318.
getWorkflowContentBounds grows the bounds using Math.max(labelRect.width, label.scrollWidth) read from the live DOM. The live label (invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx:99-111) is noOfLines={1} plus labelSxdisplay: 'flex', i.e. overflow:hidden with wrapping text. Measured in Chrome for a 120px-wide box with the label text "Positive Prompt Conditioning Collection Field":
live layout (either the flex or the -webkit-box variant): clientWidth 120, scrollWidth 120 -> overflowWidth === 0, bounds unchanged.
after setWorkflowExportInputFieldTitleStyles applies display:block; white-space:nowrap; overflow:visible to the clone: scrollWidth 236.
So the clone renders the label ~116px wider than anything the bounds calculation could see, and EXPORT_PADDING is only 100. Trigger: a node on the right edge of the workflow with a multi-word input label wider than its field column - its label is cut off at the PNG border. The label-bounds branch is effectively dead code as written.
The test that claims to cover this, invokeai/frontend/web/src/features/nodes/util/workflowImageExport.edgeCases.test.ts:132-148 ("includes overflowing input labels in content bounds"), passes scrollWidth: 100 for a rect of width: 100 - zero overflow. It never exercises the branch it is named after.
To expose this issue, add a test that feeds getWorkflowContentBounds a label whose scrollWidth exceeds its getBoundingClientRect().width and asserts the returned width grows by the overflow amount; then add a test that fixes the ordering contract - that the value fed into bounds is measured with the export label styles applied, not the live clamped ones.
3. Low: exports wider than 8192 logical px silently drop below the documented 2x resolution
invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:191-198 and :209-220; documented as 2x in the docs card added by this PR.
canvasWidth = width * EXPORT_SCALE with pixelRatio: 1. In html-to-image@1.11.13lib/index.js:89-95, canvas.width = canvasWidth * ratio, then checkCanvasDimensions runs because skipAutoScale is not set. lib/util.js:153-177 clamps any dimension above 16384 and rescales the other proportionally. So once padded content exceeds 8192 logical px in either axis (roughly 25 nodes laid out horizontally at NODE_WIDTH 320 plus spacing - routine for real workflows), the output silently falls below 2x, with no log line and no user-visible notice. The claim "2x resolution" in the PR description and in docs/src/content/docs/features/Workflows/editor-interface.mdx is unconditional.
To expose this issue, add a test that asserts getWorkflowImageDimensions either clamps canvasWidth/canvasHeight to the 16384 limit itself or reports the effective scale, so the degradation is explicit rather than delegated to library-internal auto-scaling.
4. Low: hardcoded English 'My Workflow' filename fallback bypasses the existing translation key
invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:117 and :231-241.
getInitialWorkflow() in invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts:112-114 sets name: '', so every unsaved workflow exports as My Workflow.png. The filename is user-visible (download dialog, file system), and the repository already localizes this exact concept - workflows.unnamedWorkflow = "Unnamed Workflow" exists at invokeai/frontend/web/public/locales/en.json:2747. The new string is not in en.json at all. The two strings the PR did add (nodes.downloadWorkflowImage, nodes.downloadWorkflowImageError) are correctly keyed and consumed via t() at invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx:58,112-113.
To expose this issue, add a test that asserts the blank-name fallback resolves through a translation key rather than the module-local DEFAULT_WORKFLOW_IMAGE_FILENAME constant (sanitizeWorkflowImageFilename would need to take the fallback as an argument).
5. Low: the click handler has no test coverage at all
Three behaviors the PR description calls out are untested: the duplicate-click guard (isExportingWorkflow), the missing-#workflow-editor path that fires the error toast with no logged error, and the .catch -> toast wiring. All export tests target invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts helpers only. There is no approved DOM testing framework in this repo, so a rendered-component test is not available; the reachable coverage is to extract the guard/dispatch logic into a plain function under invokeai/frontend/web/src/features/nodes/util/ and unit-test it with vitest. Otherwise this needs an explicit manual-verification note: double-click the camera button during a slow export and confirm only one download fires, and delete #workflow-editor from the DOM and confirm the error toast appears.
1. Medium-High: aspect-ratio is missing from the style allowlist, so the Current Image node collapses in the exported PNG
invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:9-103 (EXPORT_STYLE_PROPERTIES), consumed at invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:216.
html-to-image serializes the clone into an SVG foreignObject data URL, which carries none of the app stylesheets, so every visual property must be inlined from computed style. In html-to-image@1.11.13lib/clone-node.js:164-191, cloneCSSStyle only takes the sourceStyle.cssText fast path when computed cssText is non-empty. I measured this in Chrome: getComputedStyle(el).cssText === '' (length 0). So the getStyleProperties(options) branch always runs, and the allowlist is the complete styling contract - anything not listed is dropped.
aspect-ratio is not in EXPORT_STYLE_PROPERTIES.
invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx:74 sets aspectRatio="1/1" on the Flex that gives the Current Image node its entire body height (parent is NonInvocationNodeWrapper with width={384}; there is no explicit height). invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldCollectionInputComponent.tsx:156 does the same for collection thumbnails.
Measured in Chrome: a width:100px; aspect-ratio:1/1; display:flex; flex-direction:column box with a height:100% child is 100px tall; removing only aspect-ratio makes it 0px tall.
Trigger: export any workflow containing a Current Image node, or a node with an image-collection input. The node renders collapsed in the PNG, while getNodesBounds reserved its full on-screen size and the edge paths were baked from live coordinates - so the node is both wrong-sized and detached from its connectors.
To expose this issue, add a test that asserts EXPORT_STYLE_PROPERTIES contains every CSS property the workflow node tree depends on for layout, seeded with aspect-ratio (the same shape as the existing invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts:68-72 assertion, which already guards text-overflow / -webkit-line-clamp / -webkit-box-orient).
2. Medium: input-label overflow is measured against the pre-export layout, so wide labels are clipped by the canvas edge
Measurement at invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:171-186; the conflicting mutation at invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:311-318.
getWorkflowContentBounds grows the bounds using Math.max(labelRect.width, label.scrollWidth) read from the live DOM. The live label (invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx:99-111) is noOfLines={1} plus labelSxdisplay: 'flex', i.e. overflow:hidden with wrapping text. Measured in Chrome for a 120px-wide box with the label text "Positive Prompt Conditioning Collection Field":
live layout (either the flex or the -webkit-box variant): clientWidth 120, scrollWidth 120 -> overflowWidth === 0, bounds unchanged.
after setWorkflowExportInputFieldTitleStyles applies display:block; white-space:nowrap; overflow:visible to the clone: scrollWidth 236.
So the clone renders the label ~116px wider than anything the bounds calculation could see, and EXPORT_PADDING is only 100. Trigger: a node on the right edge of the workflow with a multi-word input label wider than its field column - its label is cut off at the PNG border. The label-bounds branch is effectively dead code as written.
The test that claims to cover this, invokeai/frontend/web/src/features/nodes/util/workflowImageExport.edgeCases.test.ts:132-148 ("includes overflowing input labels in content bounds"), passes scrollWidth: 100 for a rect of width: 100 - zero overflow. It never exercises the branch it is named after.
To expose this issue, add a test that feeds getWorkflowContentBounds a label whose scrollWidth exceeds its getBoundingClientRect().width and asserts the returned width grows by the overflow amount; then add a test that fixes the ordering contract - that the value fed into bounds is measured with the export label styles applied, not the live clamped ones.
3. Low: exports wider than 8192 logical px silently drop below the documented 2x resolution
invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:191-198 and :209-220; documented as 2x in the docs card added by this PR.
canvasWidth = width * EXPORT_SCALE with pixelRatio: 1. In html-to-image@1.11.13lib/index.js:89-95, canvas.width = canvasWidth * ratio, then checkCanvasDimensions runs because skipAutoScale is not set. lib/util.js:153-177 clamps any dimension above 16384 and rescales the other proportionally. So once padded content exceeds 8192 logical px in either axis (roughly 25 nodes laid out horizontally at NODE_WIDTH 320 plus spacing - routine for real workflows), the output silently falls below 2x, with no log line and no user-visible notice. The claim "2x resolution" in the PR description and in docs/src/content/docs/features/Workflows/editor-interface.mdx is unconditional.
To expose this issue, add a test that asserts getWorkflowImageDimensions either clamps canvasWidth/canvasHeight to the 16384 limit itself or reports the effective scale, so the degradation is explicit rather than delegated to library-internal auto-scaling.
4. Low: hardcoded English 'My Workflow' filename fallback bypasses the existing translation key
invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:117 and :231-241.
getInitialWorkflow() in invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts:112-114 sets name: '', so every unsaved workflow exports as My Workflow.png. The filename is user-visible (download dialog, file system), and the repository already localizes this exact concept - workflows.unnamedWorkflow = "Unnamed Workflow" exists at invokeai/frontend/web/public/locales/en.json:2747. The new string is not in en.json at all. The two strings the PR did add (nodes.downloadWorkflowImage, nodes.downloadWorkflowImageError) are correctly keyed and consumed via t() at invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx:58,112-113.
To expose this issue, add a test that asserts the blank-name fallback resolves through a translation key rather than the module-local DEFAULT_WORKFLOW_IMAGE_FILENAME constant (sanitizeWorkflowImageFilename would need to take the fallback as an argument).
5. Low: the click handler has no test coverage at all
Three behaviors the PR description calls out are untested: the duplicate-click guard (isExportingWorkflow), the missing-#workflow-editor path that fires the error toast with no logged error, and the .catch -> toast wiring. All export tests target invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts helpers only. There is no approved DOM testing framework in this repo, so a rendered-component test is not available; the reachable coverage is to extract the guard/dispatch logic into a plain function under invokeai/frontend/web/src/features/nodes/util/ and unit-test it with vitest. Otherwise this needs an explicit manual-verification note: double-click the camera button during a slow export and confirm only one download fires, and delete #workflow-editor from the DOM and confirm the error toast appears.
Label measurement after export styles are applied.
Canvas-size limit with preserved aspect ratio.
Localized unnamed-workflow filename fallback.
Added regression tests.
I'm not making the click-handler DOM test. There's no approved DOM test framework, and this is simple guarded/error-handled logic. Utility and integration coverage are present.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds camera-button workflow PNG export.
.pngdownload.html-to-image.QA Instructions
Build the frontend, click the camera icon when in Workflows. An empty workflow will render a small grid to the PNG, while a real workflow will render the entire workflow.
Related Issues / Discussions
Closes #5076
Merge Plan
Checklist
What's Newcopy (if doing a release after this PR)