diff --git a/archify.zip b/archify.zip index a1a87608b..c11438db1 100644 Binary files a/archify.zip and b/archify.zip differ diff --git a/archify/bin/visual-check.mjs b/archify/bin/visual-check.mjs index ceb5054f5..44832ddb3 100644 --- a/archify/bin/visual-check.mjs +++ b/archify/bin/visual-check.mjs @@ -1966,10 +1966,10 @@ function observation({ width, height, theme, metrics }) { && readabilityOk && Number.isFinite(minimumProjectedNodeTextPx) && ((readerLayout === 'adaptive' && readerOverflow === 'authored' && (readerFit === 'intrinsic-height' || readerFit === 'width-first')) - || (readerFit === 'authored-height' && metrics.diagramType === 'architecture' + || (readerFit === 'authored-height' && (metrics.diagramType === 'architecture' || metrics.diagramType === 'workflow') && metrics.documentScrollUnclipped === true)) ); - const authoredClipped = readerFit === 'authored-height' && metrics.diagramType === 'architecture' + const authoredClipped = readerFit === 'authored-height' && (metrics.diagramType === 'architecture' || metrics.diagramType === 'workflow') && metrics.documentScrollUnclipped !== true; const containmentOk = !authoredClipped && !overflowX && (!overflowY || verticalScrollAccepted); const legendDockIntersectionArea = Number(metrics.legendDockIntersectionArea) || 0; @@ -2183,7 +2183,7 @@ function observationDiagnostics({ artifact, allObservations, readabilityObservat })); } if (!entry.ok) { - const authoredClipped = entry.readerFit === 'authored-height' && entry.diagramType === 'architecture' + const authoredClipped = entry.readerFit === 'authored-height' && (entry.diagramType === 'architecture' || entry.diagramType === 'workflow') && !entry.documentScrollUnclipped; const budgetFixes = authoredClipped ? ['restore the full SVG inside the diagram panel and allow normal document scrolling; remove internal scrollers and clipping without changing authored geometry'] @@ -2191,7 +2191,7 @@ function observationDiagnostics({ artifact, allObservations, readabilityObservat diagnostics.push(failureDiagnostic({ code: authoredClipped ? 'viewer/diagram-clipped' : 'viewer/viewport-overflow', message: authoredClipped - ? `The authored Architecture canvas is clipped or cannot scroll in the document at ${entry.width}x${entry.height} (${entry.theme}).` + ? `The authored ${entry.diagramType === 'workflow' ? 'Workflow' : 'Architecture'} canvas is clipped or cannot scroll in the document at ${entry.width}x${entry.height} (${entry.theme}).` : `The rendered artifact overflows the ${entry.width}x${entry.height} ${entry.theme} viewport.`, subject: viewportSubject(artifact, entry), evidence: { diff --git a/archify/references/delivery-contract.md b/archify/references/delivery-contract.md index bc7265d45..43fc97a9b 100644 --- a/archify/references/delivery-contract.md +++ b/archify/references/delivery-contract.md @@ -446,8 +446,9 @@ accepted only with a renderer-declared contract and measured readable text. Automatic canvases declare `data-reader-fit="intrinsic-height"` (height fitting) or `data-reader-fit="width-first"` (Sequence and Waterfall reading width); their adaptive Reader must reach its readable width and expose `data-reader-overflow="authored"`. -Architecture with an explicit `meta.viewBox` instead declares -`data-diagram-type="architecture"` and `data-reader-fit="authored-height"`: +Architecture with an explicit `meta.viewBox`, and schema-v2 workflows with at +least two lanes and an explicit `meta.viewBox` whose height is greater than +800, declare `data-diagram-type="architecture"` (or `"workflow"`) and `data-reader-fit="authored-height"`: its SVG coordinates, aspect ratio and existing Reader width behavior stay unchanged. Its full SVG must remain inside the diagram panel without internal scrolling or clipping, and the document must permit vertical scrolling. diff --git a/archify/renderers/shared/desktop-readability.mjs b/archify/renderers/shared/desktop-readability.mjs index d4d1d0ec5..b92dc2cbd 100644 --- a/archify/renderers/shared/desktop-readability.mjs +++ b/archify/renderers/shared/desktop-readability.mjs @@ -105,7 +105,7 @@ export function predictedFixedWidthOverflow({ if (![viewBoxWidth, viewBoxHeight].every(Number.isFinite) || viewBoxWidth <= 0 || viewBoxHeight <= 0) return null; const ratio = viewBoxWidth / viewBoxHeight; if (readerFit === 'intrinsic-height' || readerFit === 'width-first' - || (readerFit === 'authored-height' && diagramType === 'architecture') + || (readerFit === 'authored-height' && (diagramType === 'architecture' || diagramType === 'workflow')) || ratio >= DECLARED_WIDE_READER_RATIO) return null; const svgWidthPx = viewport.width - bodyHorizontalPx - diagramHorizontalPx; const svgHeightPx = Math.round(svgWidthPx * viewBoxHeight / viewBoxWidth); diff --git a/archify/renderers/workflow/workflow-compiler.mjs b/archify/renderers/workflow/workflow-compiler.mjs index df754514a..56120af76 100644 --- a/archify/renderers/workflow/workflow-compiler.mjs +++ b/archify/renderers/workflow/workflow-compiler.mjs @@ -5476,14 +5476,18 @@ function renderLegend() { } function renderSvg() { - // A renderer-sized canvas that would overflow the desktop page at full - // width reads at page width with vertical scroll, like a tall stack. - const readerFit = workflow.schema_version === 2 - && !workflow.meta?.viewBox - && ((hasVerticalStack(workflow) && asArray(layout.laneHeights).some((height) => height > 104)) - || predictedFixedWidthOverflow({ viewBoxWidth: viewBox[0], viewBoxHeight: viewBox[1], diagramType: 'workflow' })) - ? ' data-reader-fit="width-first"' - : ''; + const isAuthoredHeight = Boolean(workflow.schema_version === 2 + && workflow.meta?.viewBox + && workflow.lanes?.length >= 2 + && viewBox[1] > 800); + const readerFit = isAuthoredHeight + ? ' data-diagram-type="workflow" data-reader-fit="authored-height"' + : (workflow.schema_version === 2 + && !workflow.meta?.viewBox + && ((hasVerticalStack(workflow) && asArray(layout.laneHeights).some((height) => height > 104)) + || predictedFixedWidthOverflow({ viewBoxWidth: viewBox[0], viewBoxHeight: viewBox[1], diagramType: 'workflow' })) + ? ' data-reader-fit="width-first"' + : ''); const contract = workflow.schema_version === 2 ? ' data-layout-contract="readable-v2"' : ''; return ` ${svgAccessibleText(workflow.meta, 'workflow')} diff --git a/test/delivery-contract.test.mjs b/test/delivery-contract.test.mjs index a6addbae2..ac5ebed97 100644 --- a/test/delivery-contract.test.mjs +++ b/test/delivery-contract.test.mjs @@ -56,7 +56,7 @@ test('required browser evidence stays deterministic and capture-free', () => { assert.match(browserSection, /creates one\s+`\.browser-check\.json` receipt and no screenshots or contact sheet/i); assert.match(browserSection, /visualReview: "not-requested"/); assert.match(browserSection, /Reader must reach its readable width/i); - assert.match(browserSection, /Architecture with an explicit `meta.viewBox`[\s\S]*data-reader-fit="authored-height"/); + assert.match(browserSection, /Architecture with an explicit `meta\.viewBox`.*schema-v2 workflows[\s\S]*data-reader-fit="authored-height"/); assert.match(browserSection, /explicit viewBoxes in other modes[\s\S]*remain failures/); }); diff --git a/test/desktop-reader-browser.test.mjs b/test/desktop-reader-browser.test.mjs index 54e25573d..c845e1f57 100644 --- a/test/desktop-reader-browser.test.mjs +++ b/test/desktop-reader-browser.test.mjs @@ -618,4 +618,23 @@ test('authored Architecture canvas keeps its scale and accepts readable document assert.equal(failed.exitCode, 1); assert.ok(failed.receipt.diagnostics.some(d => d.code === 'viewer/diagram-clipped'), JSON.stringify(failed.receipt.diagnostics)); } + + // Workflow with explicit viewBox: tall swimlane must accept vertical scroll without unexpected overflow (#616) + const wfInput = path.join(tmp, 'wf-input.json'); + const wfArtifact = path.join(tmp, 'wf-authored.html'); + const wfDoc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/agent-tool-call.workflow.json'), 'utf8')); + wfDoc.meta.viewBox = [1240, 838]; + fs.writeFileSync(wfInput, JSON.stringify(wfDoc)); + execFileSync(process.execPath, [path.join(skillRoot, 'bin/archify.mjs'), 'render', 'workflow', wfInput, wfArtifact]); + const wfHtml = fs.readFileSync(wfArtifact, 'utf8'); + assert.match(wfHtml, /data-diagram-type="workflow"/); + assert.match(wfHtml, /data-reader-fit="authored-height"/); + const wfResult = await runVisualCheck({ artifactPath: wfArtifact, chromePath }); + assert.equal(wfResult.exitCode, 0, JSON.stringify(wfResult.receipt.diagnostics)); + assert.ok(wfResult.receipt.containment.viewports.some((viewport) => viewport.overflowY)); + for (const viewport of wfResult.receipt.containment.viewports) { + assert.equal(viewport.readerFit, 'authored-height'); + assert.equal(viewport.overflowX, false); + if (viewport.overflowY) assert.equal(viewport.verticalScrollAccepted, true); + } }); diff --git a/test/layout-rules.test.mjs b/test/layout-rules.test.mjs index 98cd47894..065226e3f 100644 --- a/test/layout-rules.test.mjs +++ b/test/layout-rules.test.mjs @@ -1339,6 +1339,18 @@ test('architecture: measured auto canvases opt into height-aware reader fitting' assert.doesNotMatch(authoredSvg, /data-reader-min-text=/); }); +test('workflow: explicit meta.viewBox declares authored-height and data-diagram-type', () => { + const examplePath = path.join(skillRoot, 'examples/agent-tool-call.workflow.json'); + const doc = JSON.parse(fs.readFileSync(examplePath, 'utf8')); + doc.meta.viewBox = [1240, 838]; + const rendered = render('workflow', doc); + assert.equal(rendered.code, 0, rendered.stderr); + const svg = fs.readFileSync(rendered.outPath, 'utf8').match(/]*>/)?.[0]; + assert.ok(svg, 'expected an SVG root for the authored workflow canvas'); + assert.match(svg, /data-diagram-type="workflow"/); + assert.match(svg, /data-reader-fit="authored-height"/); +}); + // Automatic Sequence declares width-first; Dataflow declares intrinsic-height. // Both default canvases need an explicit automatic Reader fit below the wide ratio. for (const [mode, doc, authoredViewBox] of [ diff --git a/test/render-output-checks.test.mjs b/test/render-output-checks.test.mjs index c4c82fdc9..aadc9449b 100644 --- a/test/render-output-checks.test.mjs +++ b/test/render-output-checks.test.mjs @@ -100,7 +100,7 @@ test('render output check: predicts the certain 1440x900 overflow of a fixed-wid for (const type of ['architecture', 'workflow', '']) { const fixed = checkHtml(`viewport-authored-${type}`, node, 'showcase', '0 0 1080 780', '', ` data-reader-fit="authored-height" data-diagram-type="${type}"`); - assert.equal(fixed.result.composition.issues.some(item => item.code === 'composition/viewport-height'), type !== 'architecture'); + assert.equal(fixed.result.composition.issues.some(item => item.code === 'composition/viewport-height'), type !== 'architecture' && type !== 'workflow'); } const wide = checkHtml('viewport-height-wide', node, 'showcase', '0 0 1600 900'); diff --git a/test/visual-check.test.mjs b/test/visual-check.test.mjs index ca5ac2127..5889fd3ff 100644 --- a/test/visual-check.test.mjs +++ b/test/visual-check.test.mjs @@ -3450,12 +3450,13 @@ for (const [command, run] of [['browser-check', runBrowserCheck], ['visual-check } -test('authored Architecture scroll requires readable unclipped document flow and preserves other modes', async () => { +test('authored Architecture and Workflow scroll requires readable unclipped document flow and preserves other modes', async () => { const input = artifact('authored-scroll.html'); const target = ({ width, theme }) => width === 1440 && theme === 'light'; for (const [name, options, accepted] of [ ['readable document', {}, true], - ['other diagram mode', { authoredDiagramType: 'workflow' }, false], + ['readable workflow document', { authoredDiagramType: 'workflow' }, true], + ['other diagram mode', { authoredDiagramType: 'sequence' }, false], ['missing mode', { authoredDiagramType: null }, false], ['clipped or internally scrolled SVG', { authoredUnclipped: false }, false], ['unreadable text', { unreadableAt: target }, false], diff --git a/test/workflow-compiler.test.mjs b/test/workflow-compiler.test.mjs index 5a468046c..f5c07db9d 100644 --- a/test/workflow-compiler.test.mjs +++ b/test/workflow-compiler.test.mjs @@ -453,7 +453,8 @@ test('explicit viewBox retains shared-height readable-v2 geometry', () => { const result = compileSuccessfully(workflow); assert.deepEqual([0, 1, 2].map((index) => laneFrameRect(result.svg, index).height), [276, 270, 270]); const svgRoot = result.svg.match(/]*>/)?.[0]; - assert.equal(attributeOrUndefined(svgRoot, 'data-reader-fit'), undefined); + assert.equal(attributeOrUndefined(svgRoot, 'data-reader-fit'), 'authored-height'); + assert.equal(attributeOrUndefined(svgRoot, 'data-diagram-type'), 'workflow'); }); test('absolute route pins retain shared-height readable-v2 geometry', () => {