From 0248baa97be557ff8b37aa08f67889e9ffa4fca9 Mon Sep 17 00:00:00 2001 From: Jarvis Jr Hermes Date: Mon, 5 Oct 2026 21:11:29 +0000 Subject: [PATCH] fix: [verified] separate Target lifecycle channels and export typography --- scripts/browser/strategy-story.cjs | 7 +- scripts/browser/target-midpoints.cjs | 132 +++++++++++++++++++++++---- scripts/build-diagrams.mjs | 13 ++- scripts/cycle-viewer.css | 3 +- scripts/target-routing.mjs | 32 +++++++ scripts/target-routing.test.mjs | 32 +++++++ 6 files changed, 199 insertions(+), 20 deletions(-) create mode 100644 scripts/target-routing.mjs create mode 100644 scripts/target-routing.test.mjs diff --git a/scripts/browser/strategy-story.cjs b/scripts/browser/strategy-story.cjs index b29fbb2..55f175f 100644 --- a/scripts/browser/strategy-story.cjs +++ b/scripts/browser/strategy-story.cjs @@ -28,8 +28,11 @@ const fs=require('node:fs'); const close=panel.getByRole('button',{name:'Close full-screen diagram',exact:true});await close.waitFor({state:'visible'}); assert.equal(await close.innerText(),'×');assert(await close.evaluate(n=>n===document.activeElement)); const box=await close.boundingBox();assert.equal(box.width,44);assert.equal(box.height,44); - await close.click();assert(await opener.evaluate(n=>n===document.activeElement)); - await opener.click();await close.waitFor({state:'visible'});await page.keyboard.press('Escape');await page.waitForFunction(()=>!document.querySelector('.archify-panel--story dialog').open);assert(await opener.evaluate(n=>n===document.activeElement)); + await close.click();await panel.locator('dialog iframe').waitFor({state:'detached'});assert(await opener.evaluate(n=>n===document.activeElement)); + await opener.click();await close.waitFor({state:'visible'});await page.keyboard.press('Escape');await page.waitForFunction(()=>!document.querySelector('.archify-panel--story dialog').open); + // Native close clears open before the queued React onClose restores focus. + // Wait for its unmount before focusing the next keyboard interaction target. + await panel.locator('dialog iframe').waitFor({state:'detached'});assert(await opener.evaluate(n=>n===document.activeElement)); if(id !== 'measurement') { const summary=page.locator('.strategy-content > details > summary').first(); await summary.focus();await page.keyboard.press('Enter'); diff --git a/scripts/browser/target-midpoints.cjs b/scripts/browser/target-midpoints.cjs index d2e7518..fcdeccc 100644 --- a/scripts/browser/target-midpoints.cjs +++ b/scripts/browser/target-midpoints.cjs @@ -1,14 +1,12 @@ const {chromium} = require('playwright'); const assert = require('node:assert/strict'); const fs = require('node:fs'); +const {pathToFileURL} = require('node:url'); +const {resolve} = require('node:path'); // Inspect the rendered paths, not just the authored JSON. Also runs on downloaded SVGs. -function inspect({mobile, xml}) { - let svg = document.querySelector('svg[role="img"]'); - if (xml) { - svg = new DOMParser().parseFromString(xml, 'image/svg+xml').documentElement; - document.body.appendChild(svg); - } +function inspect({mobile}) { + const svg = document.querySelector('svg[role="img"]'); const point = (element, p) => new DOMPoint(p.x, p.y).matrixTransform(element.getCTM()); const boxes = [...svg.querySelectorAll('[data-node-id]')].map(node => { const rect = node.querySelector('rect'), box = rect.getBBox(); @@ -17,6 +15,22 @@ function inspect({mobile, xml}) { }); const agent = boxes.find(n => n.id === 'agent'); const errors = [], endpoints = {}; + const phases = ['scope','architecture','ui-ux','planning','delivery','uat','security','releases']; + const edges = [...svg.querySelectorAll('path[data-edge-id]')]; + if (edges.length !== 24) errors.push(`expected 24 graph edges, got ${edges.length}`); + phases.forEach((phase, index) => { + for (const [id, from, to] of [ + [`govern-${phase}`,'agent',phase], + [`stage-${index+1}`,phase,phases[(index+1)%phases.length]], + [`contribute-${phase}`,`code-${phase}`,phase], + ]) { + const matches = edges.filter(p => p.dataset.edgeId === id); + if (matches.length !== 1 || matches[0].dataset.edgeFrom !== from || matches[0].dataset.edgeTo !== to || !matches[0].getAttribute('marker-end') || matches[0].getAttribute('marker-start')) errors.push(`${id}: graph or direction changed`); + } + }); + // CTMs put boxes and paths in the same viewport coordinate system. + // 0.01 covers floating-point error against bounds, NOT source spreading: + // shared endpoints below must be exactly identical. const paths = [...svg.querySelectorAll('path[data-edge-id^="govern-"]')]; if (paths.length !== 8) errors.push(`expected eight governance paths, got ${paths.length}`); for (const path of paths) { @@ -28,6 +42,12 @@ function inspect({mobile, xml}) { endpoints[id] = [start.x,start.y]; if (Math.hypot(start.x-expected[0],start.y-expected[1]) > 0.01) errors.push(`${id}: wrong ${side} midpoint`); const length = path.getTotalLength(); + const target = boxes.find(n => n.id === id); + const destination = mobile ? [target.x,target.cy] + : side === 'top' ? [target.cx,target.bottom] : side === 'bottom' ? [target.cx,target.y] + : side === 'left' ? [target.right,target.cy] : [target.x,target.cy]; + const end = point(path,path.getPointAtLength(length)); + if (Math.hypot(end.x-destination[0],end.y-destination[1]) > 0.01) errors.push(`${id}: destination midpoint changed`); for (let distance = 1; distance < length; distance += 2) { const p = point(path,path.getPointAtLength(distance)); for (const box of boxes) { @@ -38,8 +58,67 @@ function inspect({mobile, xml}) { } } } - if (xml) svg.remove(); - return {errors:[...new Set(errors)],endpoints}; + for (const group of mobile ? [phases] : [['scope','architecture','releases'],['planning','delivery','uat']]) { + const first = endpoints[group[0]]; + if (group.some(id => !endpoints[id] || endpoints[id][0] !== first?.[0] || endpoints[id][1] !== first?.[1])) errors.push(`source-port spreading: ${group.join(',')}`); + } + const lifecycle = []; + if (!mobile) { + for (const [id, x, y1, y2, peer] of [ + ['stage-2',850,262.5,457.5,'architecture'], ['stage-3',850,532.5,727.5,'planning'], + ['stage-6',350,727.5,532.5,'uat'], ['stage-7',350,457.5,262.5,'releases'], + ]) { + const path = svg.querySelector(`path[data-edge-id="${id}"]`); + if (!path) {errors.push(`${id}: missing lifecycle path`); continue;} + const length = path.getTotalLength(); + const a = path.getPointAtLength(0), b = path.getPointAtLength(length); + // Local SVG coordinates test exact offsets; transformed coordinates test + // node clearance at any inline/fullscreen/export scale. + if (Math.hypot(a.x-x,a.y-y1) > 0.01 || Math.hypot(b.x-x,b.y-y2) > 0.01) errors.push(`${id}: wrong shifted endpoints`); + if (!path.getAttribute('marker-end')) errors.push(`${id}: missing direction`); + const governance = paths.find(p => p.dataset.edgeTo === peer); + if (!governance) {errors.push(`${id}: missing governance peer ${peer}`); continue;} + const end = governance.getPointAtLength(governance.getTotalLength()); + if (Math.abs(Math.abs(a.x-end.x)-20) > 0.01) errors.push(`${id}: wrong attachment clearance`); + for (let distance = 1; distance < length; distance += 2) { + const local = path.getPointAtLength(distance), p = point(path,local); + if (Math.abs(local.x-x) > 0.01) errors.push(`${id}: channel not straight`); + for (const box of boxes) { + if (p.x > box.x+0.1 && p.x < box.right-0.1 && p.y > box.y+0.1 && p.y < box.bottom-0.1) errors.push(`${id}: crosses ${box.id}`); + } + } + // No positive-length collinear overlap with ANY governance segment. + // Perpendicular crossings remain legitimate in this fixed radial layout. + for (const govern of paths) { + const total = govern.getTotalLength(); + for (let d = 0; d < total - 1; d += 1) { + const p = govern.getPointAtLength(d), q = govern.getPointAtLength(d+1); + if (Math.abs(p.x-x) < 0.01 && Math.abs(q.x-x) < 0.01 && Math.min(Math.max(p.y,q.y),Math.max(y1,y2)) - Math.max(Math.min(p.y,q.y),Math.min(y1,y2)) > 0.01) errors.push(`${id}: overlaps ${govern.dataset.edgeId}`); + } + } + lifecycle.push({id,start:[a.x,a.y],end:[b.x,b.y],attachmentClearance:Math.abs(a.x-end.x)}); + } + } + return {errors:[...new Set(errors)],endpoints,lifecycle}; +} + +function inspectTypography({mobile}) { + const errors = []; + const nodes = [...document.querySelectorAll('[data-node-id]')].map(node => { + const box = node.querySelector('rect').getBoundingClientRect(); + const texts = [...node.querySelectorAll(':scope > text')]; + const bounds = texts.map(text => text.getBoundingClientRect()); + texts.forEach((text, index) => { + const expected = text.hasAttribute('data-node-label') ? (mobile ? 38 : 27) : (mobile ? 28 : 21); + if (parseFloat(getComputedStyle(text).fontSize) !== expected) errors.push(`${node.id}: wrong text size`); + const b = bounds[index]; + if (b.width <= 0 || b.height <= 0 || b.x < box.x - 1 || b.right > box.right + 1 || b.y < box.y - 1 || b.bottom > box.bottom + 1) errors.push(`${node.id}: text outside node`); + }); + if (bounds.length === 2 && bounds[0].bottom > bounds[1].top) errors.push(`${node.id}: title/subtitle overlap`); + return {id:node.id, text:texts.map(t => t.textContent), sizes:texts.map(t => getComputedStyle(t).fontSize)}; + }); + if (nodes.length !== 17) errors.push(`expected 17 nodes, got ${nodes.length}`); + return {errors, nodes}; } (async () => { @@ -60,13 +139,34 @@ function inspect({mobile, xml}) { const inline = await frame.evaluate(inspect,{mobile:width < 700}); assert.deepEqual(inline.errors,[]); await page.screenshot({path:`${output}/${width}-inline.png`,fullPage:true}); - await frame.locator('#btn-export').click(); - const [download] = await Promise.all([page.waitForEvent('download'),frame.locator('[data-format="svg"]').click()]); - assert.equal(await download.failure(),null); - const path = `${output}/${width}.svg`; - await download.saveAs(path); - const exported = await frame.evaluate(inspect,{mobile:width < 700,xml:fs.readFileSync(path,'utf8')}); - assert.deepEqual(exported.errors,[]); + const exported = []; + for (const theme of ['light','dark']) { + if (await frame.locator('html').getAttribute('data-theme') !== theme) await frame.locator('#btn-theme').click(); + await frame.waitForFunction(t => document.documentElement.dataset.theme === t, theme); + await frame.locator('#btn-export').click(); + const [download] = await Promise.all([page.waitForEvent('download'),frame.locator('[data-format="svg"]').click()]); + assert.equal(await download.failure(),null); + const path = `${output}/${width}-${theme}.svg`; + await download.saveAs(path); + // A separate SVG document has no viewer CSS to hide export regressions. + const standalone = await browser.newPage({viewport:{width:1440,height:1100},colorScheme:theme}); + await standalone.goto(pathToFileURL(resolve(path)).href); + assert.equal(await standalone.evaluate(() => document.contentType),'image/svg+xml'); + await standalone.evaluate(() => document.fonts.ready); + const geometry = await standalone.evaluate(inspect,{mobile:width < 700}); + const typography = await standalone.evaluate(inspectTypography,{mobile:width < 700}); + assert.deepEqual(geometry.errors,[]); + assert.deepEqual(typography.errors,[]); + const background = await standalone.locator('rect.c-bg-rect').evaluate(n => getComputedStyle(n).fill); + if (exported.length) assert.notEqual(background,exported[0].background,'standalone theme must change'); + // CDP captures SVG documents without Playwright's HTML-body screenshot wait. + const client = await standalone.context().newCDPSession(standalone); + const size = await standalone.evaluate(() => ({width:document.documentElement.width.baseVal.value,height:document.documentElement.height.baseVal.value})); + const image = await client.send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,...size,scale:1}}); + fs.writeFileSync(`${output}/${width}-${theme}-standalone.png`,Buffer.from(image.data,'base64')); + exported.push({theme,background,geometry,typography}); + await standalone.close(); + } await panel.getByRole('button',{name:/^Expand /}).click(); const full = await (await panel.locator('dialog iframe').elementHandle()).contentFrame(); await full.locator('svg[role="img"]').waitFor(); @@ -80,6 +180,6 @@ function inspect({mobile, xml}) { await page.close(); } assert.equal(results.length,3); - console.log('PASS midpoint geometry and no node crossings: 3 viewports × inline/fullscreen/exported SVG'); + console.log('PASS midpoint geometry: 3 viewports × inline/fullscreen; standalone SVG geometry and typography in light/dark'); } finally {await browser.close();} })().catch(error => {console.error(error);process.exitCode=1;}); diff --git a/scripts/build-diagrams.mjs b/scripts/build-diagrams.mjs index 0824562..7b9d859 100644 --- a/scripts/build-diagrams.mjs +++ b/scripts/build-diagrams.mjs @@ -2,6 +2,7 @@ import {readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, renam import {join} from 'node:path'; import {execFileSync} from 'node:child_process'; import {roadmapViewer} from './roadmap-viewer.mjs'; +import {targetRouting} from './target-routing.mjs'; import {createHash} from 'node:crypto'; const cli = 'vendor/archify/bin/archify.mjs'; const types = new Set(['architecture','workflow','sequence','dataflow','lifecycle']); @@ -39,6 +40,7 @@ for (const file of readdirSync('diagrams').sort()) { } // Verify the upstream artifact first, then add the scoped portal integration. let viewer = readFileSync(output, 'utf8'); + if (name === 'strategy-target') viewer = targetRouting(viewer); // Upstream intentionally disables preset selection in gallery embeds. Enable // only those two guards; fail closed if a future vendor update changes them. const start = viewer.indexOf('Archify.preset = (function () {'); @@ -66,7 +68,16 @@ for (const file of readdirSync('diagrams').sort()) { const checked = JSON.parse(execFileSync(process.execPath, [cli,'check',output,'--json'], {encoding:'utf8', env:{...process.env, ARCHIFY_UPDATE_CHECK_DISABLED:'1'}})); if (!checked.ok || checked.composition?.status !== 'pass') throw new Error('Roadmap final artifact failed'); writeFileSync(`.cache/archify/${name}.portal.json`, JSON.stringify({source:input, sourceSha256:hash(input), adapterSha256:hash('scripts/roadmap-viewer.mjs'), artifactSha256:hash(output), checker:checked}, null, 2)); - } else renameSync(`static/diagrams/${name}/index.delivery.json`, `.cache/archify/${name}.delivery.json`); + } else { + renameSync(`static/diagrams/${name}/index.delivery.json`, `.cache/archify/${name}.delivery.json`); + if (name === 'strategy-target') { + // The native receipt describes the PRE-integration artifact only. + const hash = path => createHash('sha256').update(readFileSync(path)).digest('hex'); + const checked = JSON.parse(execFileSync(process.execPath, [cli,'check',output,'--json'], {encoding:'utf8', env:{...process.env, ARCHIFY_UPDATE_CHECK_DISABLED:'1'}})); + if (!checked.ok || checked.composition?.status !== 'pass') throw new Error('Target final artifact failed'); + writeFileSync(`.cache/archify/${name}.portal.json`, JSON.stringify({source:input, sourceSha256:hash(input), adapterSha256:hash('scripts/target-routing.mjs'), artifactSha256:hash(output), nativeReceiptScope:'before portal integration', checker:checked}, null, 2)); + } + } } copyFileSync('vendor/archify/LICENSE','static/diagrams/ARCHIFY-LICENSE.txt'); copyFileSync('vendor/archify/THIRD_PARTY_NOTICES.md','static/diagrams/THIRD-PARTY-NOTICES.md'); diff --git a/scripts/cycle-viewer.css b/scripts/cycle-viewer.css index 40d042e..f3c6412 100644 --- a/scripts/cycle-viewer.css +++ b/scripts/cycle-viewer.css @@ -8,7 +8,8 @@ font-size: var(--cycle-label); transform: translateY(-6px); } -.diagram-container > svg text[data-detail="context"] { +/* Export strips data-detail; node ownership and the muted class survive. */ +.diagram-container > svg [data-node-id] > text.t-muted { font-size: var(--cycle-detail); transform: translateY(16px); } diff --git a/scripts/target-routing.mjs b/scripts/target-routing.mjs new file mode 100644 index 0000000..597de91 --- /dev/null +++ b/scripts/target-routing.mjs @@ -0,0 +1,32 @@ +// Owned desktop-only integration, applied AFTER native artifact verification. +// Native architecture JSON has no per-edge attachment offset. Keep the central +// governance ports native; shift only these lifecycle channels on the same sides. +export const targetLifecycleOffsets = [ + {id:'stage-2', from:'architecture', to:'ui-ux', x:870, shiftedX:850, y1:262.5, y2:457.5}, + {id:'stage-3', from:'ui-ux', to:'planning', x:870, shiftedX:850, y1:532.5, y2:727.5}, + {id:'stage-6', from:'uat', to:'security', x:330, shiftedX:350, y1:727.5, y2:532.5}, + {id:'stage-7', from:'security', to:'releases', x:330, shiftedX:350, y1:457.5, y2:262.5}, +]; + +export function targetRouting(viewer) { + for (const {id, from, to, x, shiftedX, y1, y2} of targetLifecycleOffsets) { + const oldPath = `M ${x} ${y1} L ${x} ${y2}`; + const newPath = `M ${shiftedX} ${y1} L ${shiftedX} ${y2}`; + const oldPoints = `${x},${y1};${x},${y2}`; + const newPoints = `${shiftedX},${y1};${shiftedX},${y2}`; + // Match the whole native crossover group: its mask must move with the edge. + // Trace/export use this actual path; composition checks use its point list. + const groups = [...viewer.matchAll(/]*>\s*]*\/>\s*]*\/>\s*<\/g>/g)] + .filter(([group]) => group.includes(`data-edge-id="${id}"`)); + if (groups.length !== 1) throw new Error(`Target routing drift: ${id} group`); + const group = groups[0][0]; + for (const attribute of [`data-edge-from="${from}"`, `data-edge-to="${to}"`, `data-composition-points="${oldPoints}"`, 'data-graph-role="automatic-crossover-underlay"']) { + if (!group.includes(attribute)) throw new Error(`Target routing drift: ${id} ${attribute}`); + } + if (group.split(`d="${oldPath}"`).length !== 3) throw new Error(`Target routing drift: ${id} paths`); + const shifted = group.replaceAll(`d="${oldPath}"`, `d="${newPath}"`) + .replace(`data-composition-points="${oldPoints}"`, `data-composition-points="${newPoints}"`); + viewer = viewer.replace(group, shifted); + } + return viewer; +} diff --git a/scripts/target-routing.test.mjs b/scripts/target-routing.test.mjs new file mode 100644 index 0000000..c757c5f --- /dev/null +++ b/scripts/target-routing.test.mjs @@ -0,0 +1,32 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {targetRouting, targetLifecycleOffsets} from './target-routing.mjs'; + +const fixture = targetLifecycleOffsets.map(({id, from, to, x, y1, y2}) => ` + + +`).join('\n'); + +test('Target adapter shifts precisely four paths, masks and composition points', () => { + const unrelated = ''; + const output = targetRouting(fixture + unrelated); + assert(output.endsWith(unrelated)); + let restored = output; + for (const {x, shiftedX, y1, y2} of targetLifecycleOffsets) { + assert.equal(output.split(`d="M ${shiftedX} ${y1} L ${shiftedX} ${y2}"`).length, 3); + assert(output.includes(`data-composition-points="${shiftedX},${y1};${shiftedX},${y2}"`)); + restored = restored.replaceAll(`d="M ${shiftedX} ${y1} L ${shiftedX} ${y2}"`, `d="M ${x} ${y1} L ${x} ${y2}"`) + .replace(`data-composition-points="${shiftedX},${y1};${shiftedX},${y2}"`, `data-composition-points="${x},${y1};${x},${y2}"`); + } + assert.equal(restored, fixture + unrelated, 'all other bytes preserved'); +}); + +test('Target adapter fails closed on missing, duplicate, moved or changed native edges', () => { + for (const changed of [fixture.replace('stage-2', 'missing'), fixture + fixture, + fixture.replace('870 262.5', '871 262.5'), fixture.replace('870,262.5', '871,262.5'), + fixture.replace('data-edge-from="architecture"', 'data-edge-from="scope"'), + fixture.replace('automatic-crossover-underlay', 'unknown')]) { + assert.throws(() => targetRouting(changed), /Target routing drift/); + } + assert.throws(() => targetRouting(targetRouting(fixture)), /Target routing drift/); +});