Compare commits

..
Author SHA1 Message Date
jarvis-at-skic 2a4e058684 fix: [verified] simplify Roadmap adoption timeline
Build and publish Eldorado documentation / build (pull_request) Successful in 54s
2026-10-06 07:25:53 +00:00
jarvis-at-skic 63f38597a6 Merge pull request 'fix: separate Target lifecycle channels and standalone typography' (#27) from fix/standalone-svg-typography into main
Build and publish Eldorado documentation / build (push) Successful in 55s
2026-10-05 14:13:27 -07:00
jarvis-at-skic 0248baa97b fix: [verified] separate Target lifecycle channels and export typography
Build and publish Eldorado documentation / build (pull_request) Successful in 50s
2026-10-05 21:11:29 +00:00
jarvis-at-skic 7e3696d2d7 Merge pull request 'fix: share Target central-agent edge midpoints' (#26) from fix/target-midpoint-connectors into main
Build and publish Eldorado documentation / build (push) Successful in 40s
2026-10-05 13:14:33 -07:00
jarvis-at-skic 15c5dcb31d fix: align Target governance links to shared edge midpoints
Build and publish Eldorado documentation / build (pull_request) Successful in 38s
2026-10-05 20:12:20 +00:00
jarvis-at-skic 64b2d6dae8 Merge pull request 'docs: rename Target contributors to AI Agent' (#25) from docs/target-ai-agent into main
Build and publish Eldorado documentation / build (push) Successful in 45s
2026-10-05 12:54:21 -07:00
jarvis-at-skic 20e6f4899c docs: rename Target contributors to AI Agent
Build and publish Eldorado documentation / build (pull_request) Successful in 50s
2026-10-05 19:51:04 +00:00
jarvis-at-skic 2f68e60cec Merge pull request 'docs: redesign Roadmap as a native five-stage workflow' (#24) from docs/roadmap-workflow into main
Build and publish Eldorado documentation / build (push) Successful in 39s
2026-10-05 12:36:42 -07:00
jarvis-at-skic e149131307 docs: redesign Roadmap as a native five-stage workflow
Build and publish Eldorado documentation / build (pull_request) Successful in 39s
2026-10-05 19:35:14 +00:00
jarvis-at-skic 2d63313180 Merge pull request 'docs: start Roadmap with code-quality and process assessment' (#23) from docs/assessment-first-roadmap into main
Build and publish Eldorado documentation / build (push) Successful in 41s
2026-10-05 11:51:54 -07:00
27 changed files with 948 additions and 270 deletions

No files matched your search

@@ -1,110 +0,0 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "Recommended roadmap: assess before adoption",
"output": "static/diagrams/strategy-roadmap-mobile/index.html",
"quality_profile": "showcase",
"legend": {
"mode": "hidden"
}
},
"components": [
{
"id": "assessment",
"type": "backend",
"label": "1 · Assess current state",
"sublabel": "Code quality + delivery flow",
"pos": [
20,
20
],
"size": [
180,
60
]
},
{
"id": "readiness",
"type": "backend",
"label": "2 · Improve readiness",
"sublabel": "Critical gaps + safe scope",
"pos": [
20,
140
],
"size": [
180,
60
]
},
{
"id": "pilot",
"type": "backend",
"label": "3 · Bounded pilot",
"sublabel": "Compare accepted outcomes",
"pos": [
20,
260
],
"size": [
180,
60
]
},
{
"id": "expansion",
"type": "backend",
"label": "4 · Expand on evidence",
"sublabel": "Controls + support proven",
"pos": [
20,
380
],
"size": [
180,
60
]
},
{
"id": "sustain",
"type": "backend",
"label": "5 · Sustain & improve",
"sublabel": "Monitor + reassess",
"pos": [
20,
500
],
"size": [
180,
60
]
}
],
"connections": [
{
"id": "flow-0",
"from": "assessment",
"to": "readiness",
"label": "Gaps"
},
{
"id": "flow-1",
"from": "readiness",
"to": "pilot",
"label": "Ready"
},
{
"id": "flow-2",
"from": "pilot",
"to": "expansion",
"label": "Evidence"
},
{
"id": "flow-3",
"from": "expansion",
"to": "sustain",
"label": "Review"
}
]
}
@@ -0,0 +1,117 @@
{
"schema_version": 2,
"diagram_type": "workflow",
"meta": {
"title": "Recommended adoption roadmap",
"output": "static/diagrams/strategy-roadmap-mobile/index.html",
"quality_profile": "showcase",
"animation": "none",
"legend": {
"mode": "hidden"
}
},
"lanes": [
{
"id": "adoption",
"label": "Recommended sequence"
}
],
"mainPath": [
"assessment",
"readiness",
"pilot",
"expansion",
"sustain"
],
"nodes": [
{
"id": "assessment",
"lane": "adoption",
"col": 0,
"type": "backend",
"label": "Understand today",
"sublabel": "Code quality and delivery processes understood.",
"width": 340,
"height": 240,
"yOffset": -560
},
{
"id": "readiness",
"lane": "adoption",
"col": 0,
"type": "backend",
"label": "Prepare foundations",
"sublabel": "Critical pilot gaps closed.",
"width": 340,
"height": 240,
"yOffset": -280
},
{
"id": "pilot",
"lane": "adoption",
"col": 0,
"type": "backend",
"label": "Pilot",
"sublabel": "AI skills tested against the baseline.",
"width": 340,
"height": 240,
"yOffset": 0
},
{
"id": "expansion",
"lane": "adoption",
"col": 0,
"type": "backend",
"label": "Expand",
"sublabel": "Next workflow justified by evidence.",
"width": 340,
"height": 240,
"yOffset": 280
},
{
"id": "sustain",
"lane": "adoption",
"col": 0,
"type": "backend",
"label": "Sustain",
"sublabel": "Skills reviewed and improved.",
"width": 340,
"height": 240,
"yOffset": 560
}
],
"edges": [
{
"id": "flow-0",
"from": "assessment",
"to": "readiness",
"role": "main",
"fromSide": "bottom",
"toSide": "top"
},
{
"id": "flow-1",
"from": "readiness",
"to": "pilot",
"role": "main",
"fromSide": "bottom",
"toSide": "top"
},
{
"id": "flow-2",
"from": "pilot",
"to": "expansion",
"role": "main",
"fromSide": "bottom",
"toSide": "top"
},
{
"id": "flow-3",
"from": "expansion",
"to": "sustain",
"role": "main",
"fromSide": "bottom",
"toSide": "top"
}
]
}
-110
View File
@@ -1,110 +0,0 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "Recommended roadmap: assess before adoption",
"output": "static/diagrams/strategy-roadmap/index.html",
"quality_profile": "showcase",
"legend": {
"mode": "hidden"
}
},
"components": [
{
"id": "assessment",
"type": "backend",
"label": "1 · Assess current state",
"sublabel": "Code quality + delivery flow",
"pos": [
20,
20
],
"size": [
180,
60
]
},
{
"id": "readiness",
"type": "backend",
"label": "2 · Improve readiness",
"sublabel": "Critical gaps + safe scope",
"pos": [
260,
20
],
"size": [
180,
60
]
},
{
"id": "pilot",
"type": "backend",
"label": "3 · Bounded pilot",
"sublabel": "Compare accepted outcomes",
"pos": [
260,
150
],
"size": [
180,
60
]
},
{
"id": "expansion",
"type": "backend",
"label": "4 · Expand on evidence",
"sublabel": "Controls + support proven",
"pos": [
20,
150
],
"size": [
180,
60
]
},
{
"id": "sustain",
"type": "backend",
"label": "5 · Sustain & improve",
"sublabel": "Monitor + reassess",
"pos": [
20,
280
],
"size": [
180,
60
]
}
],
"connections": [
{
"id": "flow-0",
"from": "assessment",
"to": "readiness",
"label": "Gaps"
},
{
"id": "flow-1",
"from": "readiness",
"to": "pilot",
"label": "Ready"
},
{
"id": "flow-2",
"from": "pilot",
"to": "expansion",
"label": "Evidence"
},
{
"id": "flow-3",
"from": "expansion",
"to": "sustain",
"label": "Review"
}
]
}
+112
View File
@@ -0,0 +1,112 @@
{
"schema_version": 2,
"diagram_type": "workflow",
"meta": {
"title": "Recommended adoption roadmap",
"output": "static/diagrams/strategy-roadmap/index.html",
"quality_profile": "showcase",
"animation": "none",
"legend": {
"mode": "hidden"
}
},
"lanes": [
{
"id": "adoption",
"label": "Recommended sequence"
}
],
"mainPath": [
"assessment",
"readiness",
"pilot",
"expansion",
"sustain"
],
"nodes": [
{
"id": "assessment",
"lane": "adoption",
"col": 0,
"type": "backend",
"label": "Understand today",
"sublabel": "Code quality and delivery processes understood.",
"width": 340,
"height": 240
},
{
"id": "readiness",
"lane": "adoption",
"col": 1,
"type": "backend",
"label": "Prepare foundations",
"sublabel": "Critical pilot gaps closed.",
"width": 340,
"height": 240
},
{
"id": "pilot",
"lane": "adoption",
"col": 2,
"type": "backend",
"label": "Pilot",
"sublabel": "AI skills tested against the baseline.",
"width": 340,
"height": 240
},
{
"id": "expansion",
"lane": "adoption",
"col": 3,
"type": "backend",
"label": "Expand",
"sublabel": "Next workflow justified by evidence.",
"width": 340,
"height": 240
},
{
"id": "sustain",
"lane": "adoption",
"col": 4,
"type": "backend",
"label": "Sustain",
"sublabel": "Skills reviewed and improved.",
"width": 340,
"height": 240
}
],
"edges": [
{
"id": "flow-0",
"from": "assessment",
"to": "readiness",
"role": "main",
"fromSide": "right",
"toSide": "left"
},
{
"id": "flow-1",
"from": "readiness",
"to": "pilot",
"role": "main",
"fromSide": "right",
"toSide": "left"
},
{
"id": "flow-2",
"from": "pilot",
"to": "expansion",
"role": "main",
"fromSide": "right",
"toSide": "left"
},
{
"id": "flow-3",
"from": "expansion",
"to": "sustain",
"role": "main",
"fromSide": "right",
"toSide": "left"
}
]
}
@@ -12,14 +12,14 @@
{"id":"uat","type":"backend","label":"UAT","sublabel":"06","pos":[100,1540],"size":[240,100]}, {"id":"uat","type":"backend","label":"UAT","sublabel":"06","pos":[100,1540],"size":[240,100]},
{"id":"security","type":"backend","label":"Security","sublabel":"07","pos":[100,1760],"size":[240,100]}, {"id":"security","type":"backend","label":"Security","sublabel":"07","pos":[100,1760],"size":[240,100]},
{"id":"releases","type":"backend","label":"Releases","sublabel":"08","pos":[100,1980],"size":[240,100]}, {"id":"releases","type":"backend","label":"Releases","sublabel":"08","pos":[100,1980],"size":[240,100]},
{"id":"code-scope","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[400,440],"size":[240,100]}, {"id":"code-scope","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[400,440],"size":[240,100]},
{"id":"code-architecture","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[400,660],"size":[240,100]}, {"id":"code-architecture","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[400,660],"size":[240,100]},
{"id":"code-ui-ux","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[400,880],"size":[240,100]}, {"id":"code-ui-ux","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[400,880],"size":[240,100]},
{"id":"code-planning","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[400,1100],"size":[240,100]}, {"id":"code-planning","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[400,1100],"size":[240,100]},
{"id":"code-delivery","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[400,1320],"size":[240,100]}, {"id":"code-delivery","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[400,1320],"size":[240,100]},
{"id":"code-uat","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[400,1540],"size":[240,100]}, {"id":"code-uat","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[400,1540],"size":[240,100]},
{"id":"code-security","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[400,1760],"size":[240,100]}, {"id":"code-security","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[400,1760],"size":[240,100]},
{"id":"code-releases","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[400,1980],"size":[240,100]} {"id":"code-releases","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[400,1980],"size":[240,100]}
], ],
"connections": [ "connections": [
+16 -16
View File
@@ -12,25 +12,25 @@
{"id":"uat","type":"backend","label":"UAT","sublabel":"06","pos":[240,727.5],"size":[180,75]}, {"id":"uat","type":"backend","label":"UAT","sublabel":"06","pos":[240,727.5],"size":[180,75]},
{"id":"security","type":"backend","label":"Security","sublabel":"07","pos":[240,457.5],"size":[180,75]}, {"id":"security","type":"backend","label":"Security","sublabel":"07","pos":[240,457.5],"size":[180,75]},
{"id":"releases","type":"backend","label":"Releases","sublabel":"08","pos":[240,187.5],"size":[180,75]}, {"id":"releases","type":"backend","label":"Releases","sublabel":"08","pos":[240,187.5],"size":[180,75]},
{"id":"code-scope","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[510,37.5],"size":[180,75]}, {"id":"code-scope","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[510,37.5],"size":[180,75]},
{"id":"code-architecture","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[780,37.5],"size":[180,75]}, {"id":"code-architecture","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[780,37.5],"size":[180,75]},
{"id":"code-ui-ux","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[1005,457.5],"size":[180,75]}, {"id":"code-ui-ux","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[1005,457.5],"size":[180,75]},
{"id":"code-planning","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[780,877.5],"size":[180,75]}, {"id":"code-planning","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[780,877.5],"size":[180,75]},
{"id":"code-delivery","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[510,877.5],"size":[180,75]}, {"id":"code-delivery","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[510,877.5],"size":[180,75]},
{"id":"code-uat","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[240,877.5],"size":[180,75]}, {"id":"code-uat","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[240,877.5],"size":[180,75]},
{"id":"code-security","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[15,457.5],"size":[180,75]}, {"id":"code-security","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[15,457.5],"size":[180,75]},
{"id":"code-releases","type":"frontend","label":"Code agent","sublabel":"Human-controlled","pos":[240,37.5],"size":[180,75]} {"id":"code-releases","type":"frontend","label":"AI Agent","sublabel":"Human-controlled","pos":[240,37.5],"size":[180,75]}
], ],
"connections": [ "connections": [
{"id":"govern-scope","from":"agent","to":"scope","label":"","variant":"security","fromSide":"right","toSide":"bottom","via":[[750,495],[750,292.5],[600,292.5]]}, {"id":"govern-scope","from":"agent","to":"scope","label":"","variant":"security","fromSide":"top","toSide":"bottom","route":"straight"},
{"id":"govern-architecture","from":"agent","to":"architecture","label":"","variant":"security","fromSide":"right","toSide":"bottom","via":[[765,495],[765,345],[870,345]]}, {"id":"govern-architecture","from":"agent","to":"architecture","label":"","variant":"security","fromSide":"top","toSide":"bottom","via":[[600,345],[870,345]]},
{"id":"govern-ui-ux","from":"agent","to":"ui-ux","label":"","variant":"security","fromSide":"right","toSide":"left"}, {"id":"govern-ui-ux","from":"agent","to":"ui-ux","label":"","variant":"security","fromSide":"right","toSide":"left","route":"straight"},
{"id":"govern-planning","from":"agent","to":"planning","label":"","variant":"security","fromSide":"right","toSide":"top","via":[[765,495],[765,690],[870,690]]}, {"id":"govern-planning","from":"agent","to":"planning","label":"","variant":"security","fromSide":"bottom","toSide":"top","via":[[600,645],[870,645]]},
{"id":"govern-delivery","from":"agent","to":"delivery","label":"","variant":"security","fromSide":"bottom","toSide":"top"}, {"id":"govern-delivery","from":"agent","to":"delivery","label":"","variant":"security","fromSide":"bottom","toSide":"top","route":"straight"},
{"id":"govern-uat","from":"agent","to":"uat","label":"","variant":"security","fromSide":"left","toSide":"top","via":[[435,495],[435,690],[330,690]]}, {"id":"govern-uat","from":"agent","to":"uat","label":"","variant":"security","fromSide":"bottom","toSide":"top","via":[[600,645],[330,645]]},
{"id":"govern-security","from":"agent","to":"security","label":"","variant":"security","fromSide":"left","toSide":"right"}, {"id":"govern-security","from":"agent","to":"security","label":"","variant":"security","fromSide":"left","toSide":"right","route":"straight"},
{"id":"govern-releases","from":"agent","to":"releases","label":"","variant":"security","fromSide":"left","toSide":"bottom","via":[[435,495],[435,345],[330,345]]}, {"id":"govern-releases","from":"agent","to":"releases","label":"","variant":"security","fromSide":"top","toSide":"bottom","via":[[600,345],[330,345]]},
{"id":"stage-1","from":"scope","to":"architecture","label":"Then","variant":"emphasis","fromSide":"right","toSide":"left"}, {"id":"stage-1","from":"scope","to":"architecture","label":"Then","variant":"emphasis","fromSide":"right","toSide":"left"},
{"id":"stage-2","from":"architecture","to":"ui-ux","label":"Then","variant":"emphasis","fromSide":"bottom","toSide":"top"}, {"id":"stage-2","from":"architecture","to":"ui-ux","label":"Then","variant":"emphasis","fromSide":"bottom","toSide":"top"},
{"id":"stage-3","from":"ui-ux","to":"planning","label":"Then","variant":"emphasis","fromSide":"bottom","toSide":"top"}, {"id":"stage-3","from":"ui-ux","to":"planning","label":"Then","variant":"emphasis","fromSide":"bottom","toSide":"top"},
+3 -1
View File
@@ -15,7 +15,7 @@ description: Author typed JSON diagrams and embed their local interactive viewer
| Dataflow | Sources, processing and destinations | | Dataflow | Sources, processing and destinations |
| Lifecycle | States and transitions | | Lifecycle | States and transitions |
The standalone viewer provides pan/zoom, node inspection, search/focus, relationship tracing, light/dark themes, and an export menu. Motion is opt-in. The Organization views exercise architecture mode; other diagram types use the same build interface but do not yet have portal-specific examples. Browser-dependent exports such as video are not acceptance-tested by this integration. The standalone viewer provides pan/zoom, node inspection, search/focus, relationship tracing, light/dark themes, and an export menu. Motion is opt-in. The Organization views exercise architecture mode; Roadmap uses native Workflow v2 with a horizontal desktop path and the same stages stacked on mobile. Other diagram types use the same build interface but do not yet have portal-specific examples. Browser-dependent exports such as video are not acceptance-tested by this integration.
## Author a diagram ## Author a diagram
@@ -23,6 +23,8 @@ Create `diagrams/<name>.<type>.json` using the matching schema and authoring gui
Set `meta.output` to `static/diagrams/<name>/index.html`. The build validates the typed source with the upstream showcase profile, renders locally, checks the artifact, and publishes the standalone HTML. Editable JSON stays in the repository; the portal does not publish source-download files. Set `meta.output` to `static/diagrams/<name>/index.html`. The build validates the typed source with the upstream showcase profile, renders locally, checks the artifact, and publishes the standalone HTML. Editable JSON stays in the repository; the portal does not publish source-download files.
Roadmap has a scoped multiline typography adapter in `scripts/roadmap-viewer.mjs`. Its two `.workflow.json` sources use the native v2 schema, one lane, `mainPath`, logical columns and native routes. The native renderer validates the schema and solves geometry; the adapter wraps the existing action/output/exit copy, removes the redundant lane frame and trims empty canvas. The final artifact then passes the upstream showcase composition checker. This path records source, adapter and final artifact hashes in private `.cache/archify/*.portal.json` receipts, not upstream `deliver` provenance. Keep the vendor unchanged and run the Roadmap browser check at 1440, 390 and 320 pixels after any copy or sizing change. Typography is embedded in the SVG so exports retain it.
```sh ```sh
pnpm diagrams:build pnpm diagrams:build
pnpm test pnpm test
+1 -1
View File
@@ -11,7 +11,7 @@ import ArchifyDiagram from '@site/src/components/ArchifyDiagram';
Understand today’s code and how work really reaches production before introducing AI into the workflow. Fix what would make a pilot unsafe or impossible to evaluate—not every item of technical debt. Understand today’s code and how work really reaches production before introducing AI into the workflow. Fix what would make a pilot unsafe or impossible to evaluate—not every item of technical debt.
<ArchifyDiagram name="strategy-roadmap" title="Assess code and delivery, improve readiness, pilot, expand on evidence, then sustain" story /> <ArchifyDiagram name="strategy-roadmap" title="Understand today → Prepare foundations → Pilot → Expand → Sustain" story />
**Recommended sequence · Target operating model, not an approved schedule or deployed governance.** **Recommended sequence · Target operating model, not an approved schedule or deployed governance.**
+1 -1
View File
@@ -16,7 +16,7 @@ import ArchifyDiagram from '@site/src/components/ArchifyDiagram';
<ArchifyDiagram name="strategy-target" title="Autonomous agent executes and governs the SDLC; humans direct, guide and review" story /> <ArchifyDiagram name="strategy-target" title="Autonomous agent executes and governs the SDLC; humans direct, guide and review" story />
An autonomous agent executes and governs every phase. Humans direct, guide and review it; a separate human-controlled code agent contributes to each phase from outside the cycle. An autonomous agent executes and governs every phase. Humans direct, guide and review it; a separate human-controlled AI Agent contributes to each phase from outside the cycle.
**Solid arrows:** lifecycle · **Dotted spokes:** execution and governance · **Dashed arrows:** contributions. **Solid arrows:** lifecycle · **Dotted spokes:** execution and governance · **Dashed arrows:** contributions.
+1 -1
View File
@@ -15,7 +15,7 @@
"verify:build": "node scripts/verify.mjs --build", "verify:build": "node scripts/verify.mjs --build",
"diagrams:build": "node scripts/build-diagrams.mjs", "diagrams:build": "node scripts/build-diagrams.mjs",
"test": "node --test scripts/*.test.mjs", "test": "node --test scripts/*.test.mjs",
"test:browser": "node scripts/browser/strategy-story.cjs && node scripts/browser/inline-controls.cjs && node scripts/browser/sdlc-cycle.cjs && node scripts/browser/skills-measurement.cjs && node scripts/browser/roadmap.cjs", "test:browser": "node scripts/browser/strategy-story.cjs && node scripts/browser/inline-controls.cjs && node scripts/browser/sdlc-cycle.cjs && node scripts/browser/target-midpoints.cjs && node scripts/browser/skills-measurement.cjs && node scripts/browser/roadmap.cjs && node scripts/browser/roadmap-timeline.cjs",
"kpi:example": "node scripts/ai-kpi.mjs" "kpi:example": "node scripts/ai-kpi.mjs"
}, },
"dependencies": { "dependencies": {
+129
View File
@@ -0,0 +1,129 @@
const {chromium} = require('playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const ids = ['assessment','readiness','pilot','expansion','sustain'];
(async () => {
const base = process.argv[2] || 'http://127.0.0.1:4173';
const output = `.cache/roadmap-timeline-${process.argv[3] || 'local'}`;
fs.mkdirSync(output, {recursive:true});
const browser = await chromium.launch({headless:true, executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, args:['--no-sandbox']});
const results = [];
const inspect = async (frame, vertical) => {
await frame.locator('svg[role="img"]').waitFor();
const geometry = await frame.locator('svg[role="img"]').evaluate(svg => ({
width:innerWidth, height:innerHeight,
cards:[...svg.querySelectorAll('[data-node-id]')].map(node => ({
id:node.dataset.nodeId, box:node.querySelector('rect').getBoundingClientRect().toJSON(),
number:node.querySelector('[data-stage-number]').textContent,
numberColor:getComputedStyle(node.querySelector('[data-stage-number]')).fill,
titleColor:getComputedStyle(node.querySelector('[data-node-label]')).fill,
text:[...node.querySelectorAll('text')].map(text => ({value:text.textContent, box:text.getBoundingClientRect().toJSON(), size:parseFloat(getComputedStyle(text).fontSize)*text.getScreenCTM().a}))
}))
}));
geometry.cards.sort((a,b) => a.box.y-b.box.y || a.box.x-b.box.x);
assert.deepEqual(geometry.cards.map(n => n.id), ids);
assert.deepEqual(geometry.cards.map(n => n.number), ['1','2','3','4','5']);
for (const [index, card] of geometry.cards.entries()) {
assert.equal(card.text.length, 3, 'number, title and one outcome only');
assert.equal(card.numberColor,card.titleColor,'stage number must inherit readable theme color');
assert(card.box.left >= 0 && card.box.top >= 0 && card.box.right <= geometry.width + 1 && card.box.bottom <= geometry.height + 1, `outside iframe: ${card.id}`);
for (const text of card.text) {
assert(text.size >= 13, `small text: ${card.id} ${text.size}`);
assert(!/OUTPUT|Exit:/.test(text.value));
assert(text.box.left >= card.box.left && text.box.right <= card.box.right + 1 && text.box.top >= card.box.top && text.box.bottom <= card.box.bottom + 1, `clipped: ${card.id}`);
}
for (let t=1; t<card.text.length; t++) assert(card.text[t-1].box.bottom < card.text[t].box.top, `overlapping text: ${card.id}`);
if (index) {
const previous = geometry.cards[index-1].box;
if (vertical) {assert.equal(previous.x,card.box.x); assert(previous.bottom < card.box.top);}
else {assert.equal(previous.y,card.box.y); assert(previous.right < card.box.left);}
}
}
return geometry;
};
try {
for (const width of [1440,1024,390,320]) {
const context = await browser.newContext({viewport:{width,height:1000}, colorScheme:'light'});
try {
const page = await context.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
assert.equal((await page.goto(`${base}/eldorado-v1/documentation/strategy/roadmap/`, {waitUntil:'networkidle'})).status(),200);
const panel = page.locator('[data-diagram="strategy-roadmap"]');
const iframe = panel.locator(width < 1360 ? '.story-mobile' : '.story-desktop');
const frame = await (await iframe.elementHandle()).contentFrame();
await frame.locator('svg[role="img"]').waitFor();
const opener = panel.getByRole('button', {name:/^Expand /});
for (const theme of ['light','dark']) {
if (await frame.locator('html').getAttribute('data-theme') !== theme) await frame.locator('#btn-theme').click();
await page.waitForFunction(t => document.documentElement.dataset.theme === t, theme);
const inline = await inspect(frame,width < 1360);
await panel.screenshot({path:`${output}/${width}-${theme}-inline.png`});
await opener.focus(); await page.keyboard.press('Enter');
const close = panel.getByRole('button', {name:'Close full-screen diagram', exact:true});
await close.waitFor({state:'visible'});
const fullscreen = await (await panel.locator('dialog iframe').elementHandle()).contentFrame();
await fullscreen.waitForFunction(t => document.documentElement.dataset.theme === t, theme);
const expanded = await inspect(fullscreen,width < 1000);
await page.screenshot({path:`${output}/${width}-${theme}-fullscreen.png`});
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('[data-diagram="strategy-roadmap"] dialog').open);
assert(await opener.evaluate(n => n === document.activeElement));
assert(!await page.evaluate(() => document.documentElement.scrollWidth > innerWidth));
results.push({width,theme,inline,expanded});
fs.writeFileSync(`${output}/results.json`,JSON.stringify(results,null,2));
}
assert.deepEqual(errors,[]);
} finally {await context.close();}
}
assert.equal(results.length,8);
for (const viewport of [{width:390,height:667},{width:320,height:568}]) {
const context = await browser.newContext({viewport});
try {
const page = await context.newPage();
await page.goto(`${base}/eldorado-v1/documentation/strategy/roadmap/`, {waitUntil:'networkidle'});
const panel = page.locator('[data-diagram="strategy-roadmap"]');
const opener = panel.getByRole('button', {name:/^Expand /});
await opener.focus(); await page.keyboard.press('Enter');
const modal = panel.locator('dialog');
const close = modal.getByRole('button', {name:'Close full-screen diagram', exact:true});
await close.waitFor({state:'visible'});
const iframe = modal.locator('iframe');
const frame = await (await iframe.elementHandle()).contentFrame();
const geometry = await inspect(frame,true);
assert(await modal.evaluate(n => n.scrollHeight > n.clientHeight), 'short fullscreen must scroll');
for (const card of geometry.cards) {
await modal.evaluate((n, y) => {n.scrollTop = y;}, card.box.top);
const frameBox = await iframe.boundingBox();
const toolbarBox = await modal.locator('.archify-modal-toolbar').boundingBox();
const closeBox = await close.boundingBox();
assert(closeBox.y >= 0 && closeBox.y + closeBox.height <= viewport.height, 'close remains visible');
assert(frameBox.y + card.box.top >= toolbarBox.y + toolbarBox.height - 1 && frameBox.y + card.box.bottom <= viewport.height + 1, `stage unreachable: ${card.id}`);
}
// Controls stay reachable at the beginning of the scrollable viewer.
await modal.evaluate(n => {n.scrollTop = 0;});
for (const selector of ['#btn-theme','#btn-preset','#btn-export']) {
const control = frame.locator(selector);
const box = await control.boundingBox();
assert(box.x >= 0 && box.x + box.width <= viewport.width && box.y >= 0 && box.y + box.height <= viewport.height, `control outside viewport: ${selector}`);
await control.click();
if (selector !== '#btn-theme') await control.click();
}
await close.focus(); await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('[data-diagram="strategy-roadmap"] dialog').open);
await iframe.waitFor({state:'detached'});
assert(await opener.evaluate(n => n === document.activeElement), 'Escape restores opener focus');
await page.keyboard.press('Enter');
await close.waitFor({state:'visible'});
await close.focus(); await page.keyboard.press('Enter');
await page.waitForFunction(() => !document.querySelector('[data-diagram="strategy-roadmap"] dialog').open);
assert(await opener.evaluate(n => n === document.activeElement), 'close button restores opener focus');
results.push({viewport,scrollable:geometry});
fs.writeFileSync(`${output}/results.json`,JSON.stringify(results,null,2));
} finally {await context.close();}
}
assert.equal(results.length,10);
console.log('PASS 16 timeline contexts: 4 widths × 2 themes × inline/fullscreen; ordered readable text, no clipping, keyboard exit.');
console.log('PASS short fullscreen 390×667 and 320×568: readable stages reachable by scroll, sticky close, accessible controls, keyboard close and focus restoration.');
} finally {await browser.close();}
})().catch(error => {console.error(error); process.exitCode=1;});
+82 -4
View File
@@ -8,6 +8,50 @@ const fs = require('node:fs');
const browser = await chromium.launch({headless:true, executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, args:['--no-sandbox']}); const browser = await chromium.launch({headless:true, executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, args:['--no-sandbox']});
const results = []; const results = [];
try { try {
// Intermediate widths must use the readable vertical Roadmap, not shrink five cards.
for (const width of [768, 1024, 1280]) {
const context = await browser.newContext({viewport:{width,height:1000}, colorScheme:'light'});
try {
const page = await context.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
const response = await page.goto(`${base}/eldorado-v1/documentation/strategy/roadmap/`, {waitUntil:'networkidle'});
assert.equal(response.status(), 200);
const panel = page.locator('.archify-panel--story[data-diagram="strategy-roadmap"]');
const iframe = panel.locator('.story-mobile');
assert(await iframe.isVisible(), `${width}: vertical Roadmap iframe must be visible`);
assert(!await panel.locator('.story-desktop').isVisible(), `${width}: horizontal Roadmap must be hidden`);
const frame = await (await iframe.elementHandle()).contentFrame();
await frame.locator('svg[role="img"]').waitFor();
const geometry = await frame.locator('svg[role="img"]').evaluate(svg => ({
width:innerWidth, height:innerHeight,
overflow:document.documentElement.scrollWidth > innerWidth || document.documentElement.scrollHeight > innerHeight,
nodes:[...svg.querySelectorAll('[data-node-id]')].map(node => ({
id:node.dataset.nodeId, box:node.querySelector('rect').getBoundingClientRect().toJSON(),
text:[...node.querySelectorAll('text')].map(t => ({text:t.textContent, box:t.getBoundingClientRect().toJSON(), size:parseFloat(getComputedStyle(t).fontSize)*t.getScreenCTM().a}))
})).sort((a,b) => a.box.y-b.box.y || a.box.x-b.box.x)
}));
assert.deepEqual(geometry.nodes.map(n => n.id), ['assessment','readiness','pilot','expansion','sustain']);
for (const [index, card] of geometry.nodes.entries()) {
assert(card.text.length > 0, `${width}: missing text in ${card.id}`);
assert(card.box.left >= 0 && card.box.top >= 0 && card.box.right <= geometry.width + 1 && card.box.bottom <= geometry.height + 1, `${width}: card outside iframe: ${card.id}`);
for (const t of card.text) {
assert(t.box.left >= card.box.left && t.box.right <= card.box.right + 1 && t.box.top >= card.box.top && t.box.bottom <= card.box.bottom + 1, `${width}: clipped ${card.id} ${t.text}`);
assert(t.size >= 11, `${width}: small ${card.id} ${t.text}: ${t.size}`);
}
if (index) {
const previous = geometry.nodes[index - 1].box;
assert.equal(previous.x, card.box.x);
assert(previous.bottom < card.box.top, `${width}: stages must flow vertically`);
}
}
assert(!geometry.overflow, `${width}: iframe overflow`);
assert(!await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), `${width}: page overflow`);
assert.deepEqual(errors, []);
results.push({width, geometry, responsiveReadability:true});
fs.writeFileSync(`${output}/results.json`, JSON.stringify(results,null,2));
} finally {await context.close();}
}
for (const width of [1440, 390, 320]) { for (const width of [1440, 390, 320]) {
const context = await browser.newContext({viewport:{width,height:1000}, colorScheme:'light'}); const context = await browser.newContext({viewport:{width,height:1000}, colorScheme:'light'});
const page = await context.newPage(); const page = await context.newPage();
@@ -31,28 +75,61 @@ const fs = require('node:fs');
await iframe.scrollIntoViewIfNeeded(); await iframe.scrollIntoViewIfNeeded();
const frame = await (await iframe.elementHandle()).contentFrame(); const frame = await (await iframe.elementHandle()).contentFrame();
await frame.locator('svg[role="img"]').waitFor(); await frame.locator('svg[role="img"]').waitFor();
const checkCards = async () => {
const cards = await frame.locator('svg[role="img"] [data-node-id]').evaluateAll(nodes => nodes.map(node => {
const box = node.querySelector('rect').getBoundingClientRect();
return {id:node.dataset.nodeId, box:box.toJSON(), text:[...node.querySelectorAll('text')].map(t => ({text:t.textContent, box:t.getBoundingClientRect().toJSON(), size:parseFloat(getComputedStyle(t).fontSize)*t.getScreenCTM().a}))};
}));
for (const card of cards) for (const t of card.text) {
assert(t.box.left >= card.box.left && t.box.right <= card.box.right + 1 && t.box.top >= card.box.top && t.box.bottom <= card.box.bottom + 1, `${width}: clipped ${card.id} ${t.text}`);
assert(t.size >= 11, `${width}: small ${card.id} ${t.text}: ${t.size}`);
}
return cards;
};
await checkCards();
const geometry = await frame.locator('svg[role="img"]').evaluate(svg => ({ const geometry = await frame.locator('svg[role="img"]').evaluate(svg => ({
nodes:[...svg.querySelectorAll('[data-node-id]')].map(n => ({id:n.dataset.nodeId,...n.getBoundingClientRect().toJSON()})), nodes:[...svg.querySelectorAll('[data-node-id]')].map(n => ({id:n.dataset.nodeId,...n.getBoundingClientRect().toJSON()})).sort((a,b) => a.y-b.y || a.x-b.x),
width:innerWidth, height:innerHeight, width:innerWidth, height:innerHeight,
minLabel:Math.min(...[...svg.querySelectorAll('text[data-node-label]')].map(n => parseFloat(getComputedStyle(n).fontSize)*n.getScreenCTM().a)) minLabel:Math.min(...[...svg.querySelectorAll('text[data-node-label]')].map(n => parseFloat(getComputedStyle(n).fontSize)*n.getScreenCTM().a))
})); }));
assert.deepEqual(geometry.nodes.map(n => n.id), ['assessment','readiness','pilot','expansion','sustain']); assert.deepEqual(geometry.nodes.map(n => n.id), ['assessment','readiness','pilot','expansion','sustain']);
for (const n of geometry.nodes) assert(n.x >= 0 && n.y >= 0 && n.right <= geometry.width + 1 && n.bottom <= geometry.height + 1); for (const n of geometry.nodes) assert(n.x >= 0 && n.y >= 0 && n.right <= geometry.width + 1 && n.bottom <= geometry.height + 1);
assert(geometry.minLabel >= 10, `readable node labels: ${geometry.minLabel}`); assert(geometry.minLabel >= 10, `readable node labels: ${geometry.minLabel}`);
for (let i=1; i<geometry.nodes.length; i++) {
const a=geometry.nodes[i-1], b=geometry.nodes[i];
if(width > 700) {assert.equal(a.y,b.y); assert(a.right < b.x);}
else {assert.equal(a.x,b.x); assert(a.bottom < b.y);}
}
// Portal -> diagram and diagram -> portal are both part of the contract.
if (width <= 700) await page.getByRole('button', {name:'Toggle navigation bar'}).click();
for (let attempt=0; attempt<3; attempt++) {
await page.getByRole('button', {name:/Switch between dark and light mode/}).filter({visible:true}).click();
if (await page.locator('html').getAttribute('data-theme') === 'dark') break;
}
if (width <= 700) await page.getByRole('button', {name:'Close navigation bar'}).click();
await frame.waitForFunction(() => document.documentElement.dataset.theme === 'dark');
await frame.locator('#btn-theme').click();
await page.waitForFunction(() => document.documentElement.dataset.theme === 'light');
await frame.locator('#btn-theme').click(); await frame.locator('#btn-theme').click();
await page.waitForFunction(() => document.documentElement.dataset.theme === 'dark'); await page.waitForFunction(() => document.documentElement.dataset.theme === 'dark');
await checkCards();
await frame.locator('#btn-theme').click(); await frame.locator('#btn-theme').click();
await page.waitForFunction(() => document.documentElement.dataset.theme === 'light'); await page.waitForFunction(() => document.documentElement.dataset.theme === 'light');
for (const preset of ['blueprint','classic']) { for (const preset of ['blueprint','classic']) {
await frame.locator('#btn-preset').click(); await frame.locator('#btn-preset').click();
await frame.locator(`[data-preset-value="${preset}"]`).click(); await frame.locator(`[data-preset-value="${preset}"]`).click();
assert.equal(await frame.locator('html').getAttribute('data-preset'), preset); assert.equal(await frame.locator('html').getAttribute('data-preset'), preset);
await checkCards();
} }
await frame.locator('#btn-export').click(); await frame.locator('#btn-export').click();
const [download] = await Promise.all([page.waitForEvent('download'), frame.locator('[data-format="svg"]').click()]); const [download] = await Promise.all([page.waitForEvent('download'), frame.locator('[data-format="svg"]').click()]);
assert.equal(await download.failure(), null); assert.equal(await download.failure(), null);
await download.saveAs(`${output}/${width}.svg`); await download.saveAs(`${output}/${width}.svg`);
assert(fs.readFileSync(`${output}/${width}.svg`, 'utf8').includes('Assess current state')); assert(fs.readFileSync(`${output}/${width}.svg`, 'utf8').includes('Understand today'));
assert(fs.readFileSync(`${output}/${width}.svg`, 'utf8').includes('eldorado-roadmap-typography'));
for (const id of ['assessment','readiness','pilot','expansion','sustain']) {
await frame.locator(`svg[role="img"] [data-node-id="${id}"]`).screenshot({path:`${output}/${width}-${id}-card.png`});
}
const opener = panel.getByRole('button', {name:/^Expand /}); const opener = panel.getByRole('button', {name:/^Expand /});
await opener.click(); await opener.click();
const close = panel.getByRole('button', {name:'Close full-screen diagram', exact:true}); const close = panel.getByRole('button', {name:'Close full-screen diagram', exact:true});
@@ -67,8 +144,9 @@ const fs = require('node:fs');
await headings.first().scrollIntoViewIfNeeded(); await headings.first().scrollIntoViewIfNeeded();
await page.screenshot({path:`${output}/${width}-assessment.png`}); await page.screenshot({path:`${output}/${width}-assessment.png`});
await page.screenshot({path:`${output}/${width}-full.png`, fullPage:true}); await page.screenshot({path:`${output}/${width}-full.png`, fullPage:true});
await page.locator('.strategy-content summary').click(); await page.locator('.strategy-content summary').focus();
assert(await page.locator('.strategy-content details').evaluate(n => n.open)); await page.keyboard.press('Enter');
await page.waitForFunction(() => document.querySelector('.strategy-content details').open);
await page.locator('.strategy-content details p').last().waitFor({state:'visible'}); await page.locator('.strategy-content details p').last().waitFor({state:'visible'});
assert((await page.locator('.strategy-content details').innerText()).includes('exact-artifact approvals')); assert((await page.locator('.strategy-content details').innerText()).includes('exact-artifact approvals'));
const links = await page.locator('.strategy-content a').evaluateAll(nodes => nodes.filter(n => !n.classList.contains('hash-link')).map(n => n.href)); const links = await page.locator('.strategy-content a').evaluateAll(nodes => nodes.filter(n => !n.classList.contains('hash-link')).map(n => n.href));
+12
View File
@@ -53,6 +53,9 @@ const fs = require('node:fs');
assert.deepEqual(metrics.edges.map(e=>e.slice(0,2)), ids.map((id,i)=>[id,ids[(i+1)%ids.length]])); assert.deepEqual(metrics.edges.map(e=>e.slice(0,2)), ids.map((id,i)=>[id,ids[(i+1)%ids.length]]));
assert(metrics.edges.every(e=>e[2]?.startsWith('url(')), 'all stages must have arrowheads'); assert(metrics.edges.every(e=>e[2]?.startsWith('url(')), 'all stages must have arrowheads');
assert.equal(metrics.nodes.length, 17); assert.equal(metrics.nodes.length, 17);
assert.equal(metrics.labels.filter(n => n.text === 'AI Agent').length, 8);
assert(!metrics.labels.some(n => /code[ -]agent/i.test(n.text)));
assert((await page.locator('.strategy-content').innerText()).includes('human-controlled AI Agent'));
assert(!metrics.nodes.some(n=>n.id==='humans')); assert(!metrics.nodes.some(n=>n.id==='humans'));
assert.equal(await frame.locator('[data-node-id="agent"] text').allTextContents().then(texts=>texts.includes('Human Controlled')), true); assert.equal(await frame.locator('[data-node-id="agent"] text').allTextContents().then(texts=>texts.includes('Human Controlled')), true);
assert.equal(metrics.relationships.length, 24); assert.equal(metrics.relationships.length, 24);
@@ -74,6 +77,15 @@ const fs = require('node:fs');
} }
assert(metrics.labels.every(n=>n.px>=14), JSON.stringify(metrics.labels)); assert(metrics.labels.every(n=>n.px>=14), JSON.stringify(metrics.labels));
assert.deepEqual(metrics.overflow, []); assert.deepEqual(metrics.overflow, []);
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 exportPath = `${output}/${width}-${route ? 'target' : 'landing'}.svg`;
await download.saveAs(exportPath);
const exported = fs.readFileSync(exportPath, 'utf8');
assert.equal((exported.match(/>AI Agent<\/text>/g) || []).length, 8);
assert(!/code[ -]agent/i.test(exported));
assert(exported.includes('Autonomous agent') && exported.includes('Human Controlled'));
assert(!await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth)); assert(!await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth));
await page.evaluate(()=>scrollTo({top:0,behavior:'instant'})); await page.evaluate(()=>scrollTo({top:0,behavior:'instant'}));
await page.waitForFunction(()=>scrollY===0); await page.waitForFunction(()=>scrollY===0);
+5 -2
View File
@@ -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'}); 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)); 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); 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 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);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') { if(id !== 'measurement') {
const summary=page.locator('.strategy-content > details > summary').first(); const summary=page.locator('.strategy-content > details > summary').first();
await summary.focus();await page.keyboard.press('Enter'); await summary.focus();await page.keyboard.press('Enter');
+185
View File
@@ -0,0 +1,185 @@
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}) {
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();
const a = point(rect, box), b = point(rect, {x:box.x + box.width, y:box.y + box.height});
return {id:node.dataset.nodeId, x:a.x, y:a.y, right:b.x, bottom:b.y, cx:(a.x+b.x)/2, cy:(a.y+b.y)/2};
});
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) {
const id = path.dataset.edgeTo;
const side = mobile ? 'left' : ['scope','architecture','releases'].includes(id) ? 'top'
: ['planning','delivery','uat'].includes(id) ? 'bottom' : id === 'security' ? 'left' : 'right';
const expected = {top:[agent.cx,agent.y],bottom:[agent.cx,agent.bottom],left:[agent.x,agent.cy],right:[agent.right,agent.cy]}[side];
const start = point(path,path.getPointAtLength(0));
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) {
if (box.id === id) continue;
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}`); break;
}
}
}
}
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 () => {
const base = process.argv[2] || 'http://127.0.0.1:4173';
const output = `.cache/midpoints-${process.argv[3] || 'local'}`;
fs.mkdirSync(output,{recursive:true});
const browser = await chromium.launch({headless:true, executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE,args:['--no-sandbox']});
const results = [];
try {
for (const width of [1440,390,320]) {
const page = await browser.newPage({viewport:{width,height:1000}});
await page.goto(`${base}/eldorado-v1/documentation/strategy/target-operating-model/`,{waitUntil:'networkidle'});
const panel = page.locator('[data-diagram="strategy-target"]');
const iframe = panel.locator(`:scope > .story-${width > 700 ? 'desktop' : 'mobile'}`);
await iframe.scrollIntoViewIfNeeded();
const frame = await (await iframe.elementHandle()).contentFrame();
await frame.locator('svg[role="img"]').waitFor();
const inline = await frame.evaluate(inspect,{mobile:width < 700});
assert.deepEqual(inline.errors,[]);
await page.screenshot({path:`${output}/${width}-inline.png`,fullPage:true});
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();
// The existing fullscreen viewer always opens the radial desktop source.
const fullscreen = await full.evaluate(inspect,{mobile:false});
assert.deepEqual(fullscreen.errors,[]);
await page.screenshot({path:`${output}/${width}-fullscreen.png`});
await panel.getByRole('button',{name:'Close full-screen diagram'}).click();
results.push({width,inline,exported,fullscreen});
fs.writeFileSync(`${output}/results.json`,JSON.stringify(results,null,2));
await page.close();
}
assert.equal(results.length,3);
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;});
+33 -4
View File
@@ -1,6 +1,9 @@
import {readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, renameSync, rmSync} from 'node:fs'; import {readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, renameSync, rmSync} from 'node:fs';
import {join} from 'node:path'; import {join} from 'node:path';
import {execFileSync} from 'node:child_process'; 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 cli = 'vendor/archify/bin/archify.mjs';
const types = new Set(['architecture','workflow','sequence','dataflow','lifecycle']); const types = new Set(['architecture','workflow','sequence','dataflow','lifecycle']);
const inlineStyles = readFileSync(new URL('./inline-viewer.css', import.meta.url), 'utf8'); const inlineStyles = readFileSync(new URL('./inline-viewer.css', import.meta.url), 'utf8');
@@ -23,11 +26,21 @@ for (const file of readdirSync('diagrams').sort()) {
mkdirSync(`static/diagrams/${name}`, {recursive: true}); mkdirSync(`static/diagrams/${name}`, {recursive: true});
if (spec.diagram_type !== type || spec.meta.output !== output) throw new Error(`Diagram type/output mismatch: ${file}`); if (spec.diagram_type !== type || spec.meta.output !== output) throw new Error(`Diagram type/output mismatch: ${file}`);
const run = args => execFileSync(process.execPath,[cli,...args],{stdio:'inherit',env:{...process.env,ARCHIFY_UPDATE_CHECK_DISABLED:'1'}}); const run = args => execFileSync(process.execPath,[cli,...args],{stdio:'inherit',env:{...process.env,ARCHIFY_UPDATE_CHECK_DISABLED:'1'}});
run(['validate',type,input,'--quality','showcase','--json']); const roadmap = ['strategy-roadmap', 'strategy-roadmap-mobile'].includes(name);
run(['deliver',type,input,output,'--quality','showcase','--json']); if (roadmap) {
run(['check',output,'--json','--require-provenance']); // Native renderer validates schema and compiles layout. Check readability
// after multiline typography, not upstream's single-line preview.
execFileSync(process.execPath, ['vendor/archify/renderers/workflow/render-workflow.mjs', input, output], {stdio:'inherit', env:{...process.env, ARCHIFY_UPDATE_CHECK_DISABLED:'1'}});
writeFileSync(output, roadmapViewer(readFileSync(output, 'utf8'), spec));
run(['check',output,'--json']);
} else {
run(['validate',type,input,'--quality','showcase','--json']);
run(['deliver',type,input,output,'--quality','showcase','--json']);
run(['check',output,'--json','--require-provenance']);
}
// Verify the upstream artifact first, then add the scoped portal integration. // Verify the upstream artifact first, then add the scoped portal integration.
let viewer = readFileSync(output, 'utf8'); let viewer = readFileSync(output, 'utf8');
if (name === 'strategy-target') viewer = targetRouting(viewer);
// Upstream intentionally disables preset selection in gallery embeds. Enable // Upstream intentionally disables preset selection in gallery embeds. Enable
// only those two guards; fail closed if a future vendor update changes them. // only those two guards; fail closed if a future vendor update changes them.
const start = viewer.indexOf('Archify.preset = (function () {'); const start = viewer.indexOf('Archify.preset = (function () {');
@@ -48,7 +61,23 @@ for (const file of readdirSync('diagrams').sort()) {
} }
writeFileSync(output, viewer.replace('</head>', `<style id="eldorado-inline-controls">${inlineStyles}</style><style id="eldorado-cycle-labels">${storyStyles}</style></head>`) writeFileSync(output, viewer.replace('</head>', `<style id="eldorado-inline-controls">${inlineStyles}</style><style id="eldorado-cycle-labels">${storyStyles}</style></head>`)
.replace('</body>', `<script>${themeBridge}</script></body>`)); .replace('</body>', `<script>${themeBridge}</script></body>`));
renameSync(`static/diagrams/${name}/index.delivery.json`, `.cache/archify/${name}.delivery.json`); if (roadmap) {
// This is a portal receipt, not upstream deliver provenance: formatting
// happens between the native schema/layout compiler and artifact checker.
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('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`);
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/LICENSE','static/diagrams/ARCHIFY-LICENSE.txt');
copyFileSync('vendor/archify/THIRD_PARTY_NOTICES.md','static/diagrams/THIRD-PARTY-NOTICES.md'); copyFileSync('vendor/archify/THIRD_PARTY_NOTICES.md','static/diagrams/THIRD-PARTY-NOTICES.md');
+2 -1
View File
@@ -8,7 +8,8 @@
font-size: var(--cycle-label); font-size: var(--cycle-label);
transform: translateY(-6px); 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); font-size: var(--cycle-detail);
transform: translateY(16px); transform: translateY(16px);
} }
+62
View File
@@ -0,0 +1,62 @@
// Native Workflow v2 owns node geometry, mainPath and routing. The portal adds
// multiline typography: upstream's three single-line labels are too small for
// a five-stage reading view. Do not change the pinned vendor or graph semantics.
const escape = text => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
const wrap = (text, limit = 21) => {
const lines = [];
for (const word of text.split(/\s+/)) {
if (!lines.length || `${lines.at(-1)} ${word}`.length > limit) lines.push(word);
else lines[lines.length - 1] += ` ${word}`;
}
return lines;
};
export function roadmapViewer(html, spec) {
if (spec.diagram_type !== 'workflow' || spec.schema_version !== 2 || spec.nodes.length !== 5) throw new Error('Roadmap workflow contract drift');
const boxes = [];
for (const [index, node] of spec.nodes.entries()) {
const start = html.indexOf(`<g id="node-${node.id}"`);
const next = html.indexOf('<text data-node-label=', start);
const end = html.indexOf('</g>', next) + 4;
if (start < 0 || next < start || end < next) throw new Error(`Roadmap node drift: ${node.id}`);
const original = html.slice(start, end);
const rect = /<rect x="([\d.-]+)" y="([\d.-]+)" width="([\d.]+)" height="([\d.]+)"/.exec(original);
if (!rect || (original.match(/<text /g) || []).length !== 2) throw new Error(`Roadmap text contract drift: ${node.id}`);
const [x,y,w,h] = rect.slice(1).map(Number);
boxes.push({x,y,w,h});
const text = (value, top, size, cls, attrs = 'data-detail="context"') => {
const lines = wrap(value, size === 30 ? 18 : 21);
const maxLines = top === 160 ? 3 : 2;
if (lines.length > maxLines || lines.some(line => line.length > 21)) throw new Error(`Roadmap copy exceeds card capacity: ${node.id}`);
return `<text x="${x+24}" y="${y+top}" font-size="${size}" class="${cls}" ${attrs}>${lines.map((line,i) => `<tspan x="${x+24}" dy="${i ? 34 : 0}">${escape(line)}</tspan>`).join('')}</text>`;
};
if (!node.sublabel || node.tag || /Output:|Exit:|\n/.test(node.sublabel)) throw new Error(`Roadmap content contract drift: ${node.id}`);
const content = text(String(index+1), 40, 26, 't-primary', 'data-stage-number="" font-weight="700"')
+ text(node.label, 88, 30, 't-primary', 'data-node-label="" font-weight="700"')
+ text(node.sublabel, 160, 26, 't-muted');
// Preserve native node title/metadata and rectangles; replace sigil/text only.
// Sigils are nested SVG groups. Anchor on the two native node rectangles.
const rectangles = [...original.matchAll(/<rect\b[^>]*\/>/g)];
if (rectangles.length !== 2) throw new Error('Roadmap shape contract drift');
const prefixEnd = rectangles[1].index + rectangles[1][0].length;
const replacement = original.slice(0,prefixEnd).replaceAll('rx="6"', 'rx="0"') + content + '</g>';
html = html.slice(0,start) + replacement + html.slice(end);
}
const left = Math.min(...boxes.map(b => b.x)) - 12;
const top = Math.min(...boxes.map(b => b.y)) - 12;
const width = Math.max(...boxes.map(b => b.x+b.w)) - left + 12;
const height = Math.max(...boxes.map(b => b.y+b.h)) - top + 12;
// One editorial lane; its title/frame would repeat the page's framing.
html = html.replace(/<rect\b[^>]*data-composition-frame-kind="lane"[^>]*\/>\s*<text\b[^>]*>[\s\S]*?<\/text>/g, '');
const roots = [...html.matchAll(/<svg\b[^>]*role="img"[^>]*>/g)];
if (roots.length !== 1) throw new Error('Roadmap SVG contract drift');
const root = roots[0][0].replace(/viewBox="[^"]*"/, `viewBox="${left} ${top} ${width} ${height}"`);
const styles = `<style id="eldorado-roadmap-typography">
[data-node-id] text {font-family: Arial, sans-serif;}
svg[data-preset="classic"] [data-node-id] > rect.c-backend,
svg[data-preset="editorial"] [data-node-id] > rect.c-backend {fill:var(--panel); stroke:var(--border);}
.c-grid {opacity:0;}
</style>`;
html = html.replace(roots[0][0], root + styles);
return html;
}
+35
View File
@@ -0,0 +1,35 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync} from 'node:fs';
import {execFileSync} from 'node:child_process';
import {roadmapViewer} from './roadmap-viewer.mjs';
for (const suffix of ['', '-mobile']) test(`native Workflow${suffix} retains graph and passes final composition`, () => {
mkdirSync('.cache', {recursive:true});
const dir = mkdtempSync('.cache/roadmap-test-');
try {
const source = `diagrams/strategy-roadmap${suffix}.workflow.json`;
const spec = JSON.parse(readFileSync(source, 'utf8'));
const output = `${dir}/index.html`;
execFileSync(process.execPath, ['vendor/archify/renderers/workflow/render-workflow.mjs', source, output], {env:{...process.env, ARCHIFY_UPDATE_CHECK_DISABLED:'1'}});
const native = readFileSync(output, 'utf8');
const styled = roadmapViewer(native, spec);
const graph = html => [...html.matchAll(/<path\b[^>]*data-edge-id="[^"]+"[^>]*>/g)].map(m => m[0]);
assert.equal(graph(native).length, 4);
assert.deepEqual(graph(styled), graph(native), 'native paths must not be rewritten');
for (const node of spec.nodes) assert(styled.includes(`data-node-id="${node.id}"`));
assert.equal((styled.match(/<text data-node-label=/g) || []).length, 0, 'single-line preview replaced');
assert(styled.includes('eldorado-roadmap-typography'));
writeFileSync(output, styled);
const checked = JSON.parse(execFileSync(process.execPath, ['vendor/archify/bin/archify.mjs','check',output,'--json'], {encoding:'utf8', env:{...process.env, ARCHIFY_UPDATE_CHECK_DISABLED:'1'}}));
assert(checked.ok);
assert.equal(checked.composition.status, 'pass');
assert.equal(checked.composition.metrics.properCrossings, 0);
assert.equal(checked.composition.metrics.labelCanvasOverflowIssues, 0);
assert.throws(() => roadmapViewer('', spec), /drift/);
const oversized = structuredClone(spec);
oversized.nodes[0].sublabel = 'Too much copy '.repeat(20);
assert.throws(() => roadmapViewer(native, oversized), /capacity/);
assert.throws(() => roadmapViewer(native, {...spec,diagram_type:'architecture'}), /contract drift/);
} finally {rmSync(dir, {recursive:true, force:true});}
});
+12 -6
View File
@@ -19,11 +19,17 @@ test('Roadmap visibly assesses code and real delivery before adoption', () => {
test('both roadmap diagrams make assessment and readiness prerequisites to the pilot', () => { test('both roadmap diagrams make assessment and readiness prerequisites to the pilot', () => {
const ids = ['assessment', 'readiness', 'pilot', 'expansion', 'sustain']; const ids = ['assessment', 'readiness', 'pilot', 'expansion', 'sustain'];
for (const suffix of ['', '-mobile']) { for (const suffix of ['', '-mobile']) {
const diagram = JSON.parse(read(`diagrams/strategy-roadmap${suffix}.architecture.json`)); const diagram = JSON.parse(read(`diagrams/strategy-roadmap${suffix}.workflow.json`));
assert.deepEqual(diagram.components.map(n => n.id), ids); assert.equal(diagram.schema_version, 2);
assert.deepEqual(diagram.connections.map(e => [e.from,e.to]), ids.slice(1).map((id, i) => [ids[i],id])); assert.equal(diagram.diagram_type, 'workflow');
assert.match(diagram.components[0].sublabel, /Code quality \+ delivery flow/); assert.deepEqual(diagram.mainPath, ids);
assert.match(diagram.components[1].sublabel, /Critical gaps/); assert.deepEqual(diagram.nodes.map(n => n.id), ids);
assert.match(diagram.components[4].label, /Sustain/); assert.deepEqual(diagram.nodes.map(n => n.label), ['Understand today', 'Prepare foundations', 'Pilot', 'Expand', 'Sustain']);
assert.deepEqual(diagram.edges.map(e => [e.from,e.to]), ids.slice(1).map((id, i) => [ids[i],id]));
assert.equal(diagram.nodes[0].sublabel, 'Code quality and delivery processes understood.');
assert.equal(diagram.nodes[1].sublabel, 'Critical pilot gaps closed.');
assert(diagram.nodes.every(n => !n.sublabel.includes('Output:') && !n.tag && n.height === 240));
assert.deepEqual(diagram.nodes.map(n => n.col), suffix ? [0,0,0,0,0] : [0,1,2,3,4]);
assert.equal(diagram.lanes.length, 1);
} }
}); });
+3 -2
View File
@@ -14,7 +14,8 @@ test('Strategy opens at the canonical autonomous lifecycle', () => {
assert(primary.includes('autonomous agent')); assert(primary.includes('autonomous agent'));
assert(primary.includes('direct, guide and review')); assert(primary.includes('direct, guide and review'));
assert(primary.includes('Scope → Architecture → UI/UX → Planning → Delivery → UAT → Security → Releases → Scope')); assert(primary.includes('Scope → Architecture → UI/UX → Planning → Delivery → UAT → Security → Releases → Scope'));
assert(primary.includes('human-controlled code agent')); assert(primary.includes('human-controlled AI Agent'));
assert(!/code[ -]agent/i.test(page));
assert(primary.includes('TO-BE')); assert(primary.includes('TO-BE'));
assert(!/only routes|People authorize|independent gates protect release/.test(primary)); assert(!/only routes|People authorize|independent gates protect release/.test(primary));
} }
@@ -37,7 +38,7 @@ test('both target diagrams have eight stages, eight governed phases and eight hu
assert.deepEqual(d.connections.filter(e => e.from === 'agent').map(e => e.to), ids); assert.deepEqual(d.connections.filter(e => e.from === 'agent').map(e => e.to), ids);
const contributors = d.components.filter(n => n.id.startsWith('code-')); const contributors = d.components.filter(n => n.id.startsWith('code-'));
assert.equal(contributors.length, 8); assert.equal(contributors.length, 8);
assert(contributors.every(n => n.label === 'Code agent' && n.sublabel === 'Human-controlled')); assert(contributors.every(n => n.label === 'AI Agent' && n.sublabel === 'Human-controlled'));
for (const id of ids) { for (const id of ids) {
assert(d.connections.some(e => e.from === `code-${id}` && e.to === id && e.variant === 'dashed')); assert(d.connections.some(e => e.from === `code-${id}` && e.to === id && e.variant === 'dashed'));
assert(d.connections.some(e => e.from === 'agent' && e.to === id && e.variant === 'security')); assert(d.connections.some(e => e.from === 'agent' && e.to === id && e.variant === 'security'));
+8 -1
View File
@@ -26,10 +26,17 @@ test('mobile story diagrams preserve the same nodes and directed relationships',
for (const {id} of model.pages) { for (const {id} of model.pages) {
const page = read(`docs/strategy/${id}.mdx`); const page = read(`docs/strategy/${id}.mdx`);
const name = /<ArchifyDiagram name="([^"]+)"[^>]+ story/.exec(page)[1]; const name = /<ArchifyDiagram name="([^"]+)"[^>]+ story/.exec(page)[1];
if (name === 'strategy-roadmap') {
const desktop = JSON.parse(read(`diagrams/${name}.workflow.json`));
const mobile = JSON.parse(read(`diagrams/${name}-mobile.workflow.json`));
const semantic = d => ({nodes:d.nodes.map(({col,yOffset,...n}) => n), edges:d.edges.map(({fromSide,toSide,...e}) => e)});
assert.deepEqual(semantic(desktop), semantic(mobile));
continue;
}
const desktop = JSON.parse(read(`diagrams/${name}.architecture.json`)); const desktop = JSON.parse(read(`diagrams/${name}.architecture.json`));
const mobile = JSON.parse(read(`diagrams/${name}-mobile.architecture.json`)); const mobile = JSON.parse(read(`diagrams/${name}-mobile.architecture.json`));
assert(desktop.components.length >= 5 && desktop.components.length <= (name === 'strategy-target' ? 18 : 7), name); assert(desktop.components.length >= 5 && desktop.components.length <= (name === 'strategy-target' ? 18 : 7), name);
const semantic = d => ({nodes:d.components.map(({pos,size,...n})=>n),edges:d.connections.map(({fromSide,toSide,via,labelAt,...e})=>e)}); const semantic = d => ({nodes:d.components.map(({pos,size,...n})=>n),edges:d.connections.map(({fromSide,toSide,route,via,labelAt,...e})=>e)});
assert.deepEqual(semantic(desktop), semantic(mobile), name); assert.deepEqual(semantic(desktop), semantic(mobile), name);
assert(desktop.connections.every(e => (e.label || e.id.startsWith('govern-')) && e.from !== e.to), name); assert(desktop.connections.every(e => (e.label || e.id.startsWith('govern-')) && e.from !== e.to), name);
assert.equal(desktop.meta.legend.mode, 'hidden'); assert.equal(desktop.meta.legend.mode, 'hidden');
+31
View File
@@ -0,0 +1,31 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {createRouter} from '../vendor/archify/renderers/architecture/routing.mjs';
import {segmentIntersectsRect} from '../vendor/archify/renderers/shared/geometry.mjs';
for (const suffix of ['', '-mobile']) {
test(`Target${suffix} uses shared native midpoint ports without node crossings`, () => {
const diagram = JSON.parse(readFileSync(`diagrams/strategy-target${suffix}.architecture.json`, 'utf8'));
const boxes = new Map(diagram.components.map(({id, pos: [x, y], size: [width, height]}) =>
[id, {id, x, y, width, height, cx: x + width / 2, cy: y + height / 2}]));
const router = createRouter(boxes, diagram.connections, {distinctAutomaticPorts: true});
const agent = boxes.get('agent');
for (const edge of diagram.connections.filter(edge => edge.from === 'agent')) {
const side = suffix ? 'left' : ['scope', 'architecture', 'releases'].includes(edge.to) ? 'top'
: ['planning', 'delivery', 'uat'].includes(edge.to) ? 'bottom' : edge.to === 'security' ? 'left' : 'right';
assert.equal(edge.fromSide, side, edge.id);
assert(edge.via || (edge.route && edge.route !== 'auto'), `${edge.id} must opt out of automatic port spreading`);
const expected = {top: [agent.cx, agent.y], bottom: [agent.cx, agent.y + agent.height],
left: [agent.x, agent.cy], right: [agent.x + agent.width, agent.cy]}[side];
const points = router.pathFor(edge).points;
assert.deepEqual(points[0], expected, edge.id);
for (let i = 1; i < points.length; i++) {
for (const box of boxes.values()) {
if ([edge.from, edge.to].includes(box.id)) continue;
assert(!segmentIntersectsRect({start: points[i - 1], end: points[i]}, box), `${edge.id} crosses ${box.id}`);
}
}
}
});
}
+32
View File
@@ -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(/<g data-graph-role="automatic-crossover"[^>]*>\s*<path\b[^>]*\/>\s*<path\b[^>]*\/>\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;
}
+32
View File
@@ -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}) => `<g data-graph-role="automatic-crossover" style="--step:9">
<path data-graph-role="automatic-crossover-underlay" d="M ${x} ${y1} L ${x} ${y2}"/>
<path data-edge-from="${from}" data-edge-to="${to}" data-edge-id="${id}" data-composition-points="${x},${y1};${x},${y2}" d="M ${x} ${y1} L ${x} ${y2}" marker-end="url(#arrowhead-emphasis)"/>
</g>`).join('\n');
test('Target adapter shifts precisely four paths, masks and composition points', () => {
const unrelated = '<path data-edge-id="govern-architecture" d="M 600 450 L 870 262.5"/><g data-node-id="agent"/>';
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/);
});
+10 -1
View File
@@ -11,6 +11,15 @@ export default function ArchifyDiagram({name, title, compact = false, story = fa
const opener = useRef<HTMLButtonElement>(null); const opener = useRef<HTMLButtonElement>(null);
const headingId = useId(); const headingId = useId();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [narrowRoadmap, setNarrowRoadmap] = useState(false);
useEffect(() => {
if (name !== 'strategy-roadmap') return;
const media = window.matchMedia('(max-width: 999px)');
const update = () => setNarrowRoadmap(media.matches);
update();
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, [name]);
const {colorMode, setColorMode} = useColorMode(); const {colorMode, setColorMode} = useColorMode();
const initialTheme = useRef(colorMode); const initialTheme = useRef(colorMode);
const desktop = useRef<HTMLIFrameElement>(null); const desktop = useRef<HTMLIFrameElement>(null);
@@ -65,7 +74,7 @@ export default function ArchifyDiagram({name, title, compact = false, story = fa
</> : <iframe ref={desktop} onLoad={() => syncTheme(desktop.current)} src={`${source}?theme=${initialTheme.current}`} title={title} sandbox="allow-scripts allow-downloads" loading="lazy" referrerPolicy="no-referrer" />} </> : <iframe ref={desktop} onLoad={() => syncTheme(desktop.current)} src={`${source}?theme=${initialTheme.current}`} title={title} sandbox="allow-scripts allow-downloads" loading="lazy" referrerPolicy="no-referrer" />}
<dialog ref={dialog} className="archify-modal" aria-labelledby={headingId} onClose={() => {setExpanded(false); opener.current?.focus();}}> <dialog ref={dialog} className="archify-modal" aria-labelledby={headingId} onClose={() => {setExpanded(false); opener.current?.focus();}}>
<div className="archify-modal-toolbar"><strong id={headingId}>{title}</strong><button type="button" onClick={close} autoFocus aria-label="Close full-screen diagram"><span aria-hidden="true">×</span></button></div> <div className="archify-modal-toolbar"><strong id={headingId}>{title}</strong><button type="button" onClick={close} autoFocus aria-label="Close full-screen diagram"><span aria-hidden="true">×</span></button></div>
{expanded && <iframe ref={fullscreen} onLoad={() => syncTheme(fullscreen.current)} src={`${source}?theme=${initialTheme.current}`} title={`${title} — expanded`} sandbox="allow-scripts allow-downloads" referrerPolicy="no-referrer" />} {expanded && <iframe ref={fullscreen} onLoad={() => syncTheme(fullscreen.current)} src={`${narrowRoadmap ? mobileSource : source}?${name === 'strategy-roadmap' ? 'embed=1&' : ''}theme=${initialTheme.current}`} title={`${title} — expanded`} sandbox="allow-scripts allow-downloads" referrerPolicy="no-referrer" />}
</dialog> </dialog>
</section>; </section>;
} }
+16 -1
View File
@@ -110,6 +110,9 @@ a:focus-visible, button:focus-visible {outline: 3px solid #b88719; outline-offse
.archify-panel--story {max-width: 740px;} .archify-panel--story {max-width: 740px;}
.archify-panel--story > iframe {height: auto; aspect-ratio: 480 / 408;} .archify-panel--story > iframe {height: auto; aspect-ratio: 480 / 408;}
.archify-panel--story > .story-mobile {display: none;} .archify-panel--story > .story-mobile {display: none;}
.theme-doc-markdown:has([data-diagram="strategy-roadmap"]) {max-width: none;}
.archify-panel--story[data-diagram="strategy-roadmap"] {max-width: none;}
.archify-panel--story[data-diagram="strategy-roadmap"] > .story-desktop {aspect-ratio: auto; height: 240px;}
.archify-panel--story[data-diagram="strategy-target"] > iframe {aspect-ratio: 1620 / 1430;} .archify-panel--story[data-diagram="strategy-target"] > iframe {aspect-ratio: 1620 / 1430;}
.archify-panel--story[data-diagram="strategy-adoption"] > iframe {aspect-ratio: 545 / 408;} .archify-panel--story[data-diagram="strategy-adoption"] > iframe {aspect-ratio: 545 / 408;}
.archify-panel--story[data-diagram="strategy-maintenance"] > iframe {aspect-ratio: 524 / 408;} .archify-panel--story[data-diagram="strategy-maintenance"] > iframe {aspect-ratio: 524 / 408;}
@@ -121,11 +124,16 @@ a:focus-visible, button:focus-visible {outline: 3px solid #b88719; outline-offse
@media (max-width: 700px) { @media (max-width: 700px) {
.archify-panel--story > .story-desktop {display: none;} .archify-panel--story > .story-desktop {display: none;}
.archify-panel--story > .story-mobile {display: block; height: auto; aspect-ratio: 240 / 748;} .archify-panel--story > .story-mobile {display: block; height: auto; aspect-ratio: 240 / 748;}
.archify-panel--story[data-diagram="strategy-roadmap"] > .story-mobile {aspect-ratio: 240 / 628;} .archify-panel--story[data-diagram="strategy-roadmap"] > .story-mobile {aspect-ratio: 364 / 1480;}
.archify-panel--story[data-diagram="strategy-adoption"] > .story-mobile {aspect-ratio: 305 / 748;} .archify-panel--story[data-diagram="strategy-adoption"] > .story-mobile {aspect-ratio: 305 / 748;}
.archify-panel--story[data-diagram="strategy-maintenance"] > .story-mobile {aspect-ratio: 284 / 748;} .archify-panel--story[data-diagram="strategy-maintenance"] > .story-mobile {aspect-ratio: 284 / 748;}
.archify-panel--story[data-diagram="strategy-target"] > .story-mobile {aspect-ratio: 680 / 2340;} .archify-panel--story[data-diagram="strategy-target"] > .story-mobile {aspect-ratio: 680 / 2340;}
} }
/* Five Roadmap cards need desktop space, including room for the sidebar. */
@media (width < 1360px) {
.archify-panel--story[data-diagram="strategy-roadmap"] > .story-desktop {display: none;}
.archify-panel--story[data-diagram="strategy-roadmap"] > .story-mobile {display: block; height: auto; width: 100%; max-width: 364px; margin-inline: auto; aspect-ratio: 364 / 1480;}
}
.archify-expand {display: grid; place-items: center; width: 44px; height: 44px; border: 0; padding: 0; background: transparent; color: var(--ifm-link-color); cursor: pointer;} .archify-expand {display: grid; place-items: center; width: 44px; height: 44px; border: 0; padding: 0; background: transparent; color: var(--ifm-link-color); cursor: pointer;}
.archify-expand:hover {background: var(--ifm-hover-overlay);} .archify-expand:hover {background: var(--ifm-hover-overlay);}
.archify-modal {position: fixed; inset: 0; margin: 0; padding: 0; width: 100vw; height: 100dvh; max-width: none; max-height: none; border: 0; background: var(--surface); color: var(--ifm-color-content);} .archify-modal {position: fixed; inset: 0; margin: 0; padding: 0; width: 100vw; height: 100dvh; max-width: none; max-height: none; border: 0; background: var(--surface); color: var(--ifm-color-content);}
@@ -134,4 +142,11 @@ a:focus-visible, button:focus-visible {outline: 3px solid #b88719; outline-offse
.archify-modal-toolbar {display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 20px; border-bottom: 1px solid var(--line);} .archify-modal-toolbar {display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 20px; border-bottom: 1px solid var(--line);}
.archify-modal-toolbar button {display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 44px; height: 44px; padding: 0; background: var(--gold); color: #14131a; font: inherit; font-size: 24px; font-weight: 700; border: 0; cursor: pointer;} .archify-modal-toolbar button {display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 44px; height: 44px; padding: 0; background: var(--gold); color: #14131a; font: inherit; font-size: 24px; font-weight: 700; border: 0; cursor: pointer;}
.archify-panel .archify-modal iframe {flex: 1; min-height: 0; height: 100%; width: 100%;} .archify-panel .archify-modal iframe {flex: 1; min-height: 0; height: 100%; width: 100%;}
/* Read the five-stage narrow Roadmap by scrolling, never by fitting its
entire height into a short phone screen. Other diagram viewers are unchanged. */
@media (max-width: 999px) {
[data-diagram="strategy-roadmap"] .archify-modal {overflow-y: auto; overscroll-behavior: contain;}
[data-diagram="strategy-roadmap"] .archify-modal-toolbar {position: sticky; top: 0; z-index: 1; flex-shrink: 0; background: var(--surface);}
[data-diagram="strategy-roadmap"] .archify-modal iframe {flex: none; height: 1480px; min-height: 1480px;}
}
@media (prefers-reduced-motion: reduce) {.start-card {transition: none;} .start-card:hover {transform: none;}} @media (prefers-reduced-motion: reduce) {.start-card {transition: none;} .start-card:hover {transform: none;}}