fix: restore inline diagram controls and synchronize portal theme
Build and publish Eldorado documentation / build (pull_request) Successful in 43s
Build and publish Eldorado documentation / build (pull_request) Successful in 43s
This commit is contained in:
1 parent
62d2afc152
commit
9347de298f
9 files changed
+267
-6
No files matched your search
@@ -0,0 +1,117 @@
|
||||
const {chromium} = require('playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
(async () => {
|
||||
const base = process.argv[2] || 'http://127.0.0.1:4173';
|
||||
const output = `.cache/inline-${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 context = await browser.newContext({viewport: {width, height: 1000}, colorScheme: 'light'});
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${base}/eldorado-v1/documentation/strategy/`, {waitUntil: 'networkidle'});
|
||||
const panel = page.locator('.archify-panel--story');
|
||||
const iframe = panel.locator(width > 700 ? '.story-desktop' : '.story-mobile');
|
||||
await iframe.scrollIntoViewIfNeeded();
|
||||
const frame = await (await iframe.elementHandle()).contentFrame();
|
||||
const theme = async value => {
|
||||
await page.waitForFunction(t => document.documentElement.dataset.theme === t, value);
|
||||
await frame.waitForFunction(t => document.documentElement.dataset.theme === t, value);
|
||||
};
|
||||
await theme('light');
|
||||
for (const id of ['btn-theme', 'btn-preset', 'btn-export']) {
|
||||
const box = await frame.locator(`#${id}`).boundingBox();
|
||||
assert(box && box.width >= 40 && box.height >= 44, id);
|
||||
}
|
||||
const light = await frame.locator('body').evaluate(n => getComputedStyle(n).backgroundColor);
|
||||
await frame.locator('#btn-theme').click(); await theme('dark');
|
||||
assert.notEqual(await frame.locator('body').evaluate(n => getComputedStyle(n).backgroundColor), light);
|
||||
await frame.locator('#btn-theme').click(); await theme('light');
|
||||
// Mobile portal theme control lives in the navigation drawer.
|
||||
const portalTheme = async target => {
|
||||
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') === target) break;
|
||||
}
|
||||
if (width <= 700) await page.getByRole('button', {name: 'Close navigation bar'}).click();
|
||||
};
|
||||
await portalTheme('dark'); await theme('dark');
|
||||
await portalTheme('light'); await theme('light');
|
||||
for (const preset of ['signal-flow', 'blueprint', 'editorial', 'classic']) {
|
||||
await frame.locator('#btn-preset').click();
|
||||
await frame.locator(`[data-preset-value="${preset}"]`).click();
|
||||
assert.equal(await frame.locator('html').getAttribute('data-preset'), preset);
|
||||
}
|
||||
// A theme change must not reload the iframe or lose the selected visual preset.
|
||||
await frame.locator('#btn-preset').click();
|
||||
await frame.locator('[data-preset-value="blueprint"]').click();
|
||||
await portalTheme('dark'); await theme('dark');
|
||||
assert.equal(await frame.locator('html').getAttribute('data-preset'), 'blueprint');
|
||||
const exports = [];
|
||||
for (const format of ['png', 'jpeg', 'webp', 'svg', 'svg-light', 'svg-dark', 'webm']) {
|
||||
await frame.locator('#btn-export').click();
|
||||
if (format === 'webm' && await frame.locator('[data-format="webm"]').isDisabled()) {
|
||||
exports.push({format, unsupported: true});
|
||||
await frame.locator('#btn-export').click();
|
||||
continue;
|
||||
}
|
||||
console.log(`Export ${width} ${format}`);
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download', {timeout: 60000}),
|
||||
frame.locator(`[data-format="${format}"]`).click(),
|
||||
]);
|
||||
assert.equal(await download.failure(), null);
|
||||
const path = `${output}/${width}-${format}-${download.suggestedFilename()}`;
|
||||
await download.saveAs(path);
|
||||
const bytes = fs.readFileSync(path);
|
||||
assert(bytes.length > 1000);
|
||||
if (format.startsWith('svg')) {
|
||||
const valid = await page.evaluate(xml => {
|
||||
const doc = new DOMParser().parseFromString(xml, 'image/svg+xml');
|
||||
return !doc.querySelector('parsererror') && doc.documentElement.localName === 'svg' && doc.querySelectorAll('text').length > 0;
|
||||
}, bytes.toString());
|
||||
assert(valid, format);
|
||||
} else {
|
||||
const mime = {png:'image/png', jpeg:'image/jpeg', webp:'image/webp', webm:'video/webm'}[format];
|
||||
const dimensions = await page.evaluate(async ({data, mime}) => {
|
||||
const media = document.createElement(mime.startsWith('video') ? 'video' : 'img');
|
||||
const loaded = new Promise((resolve, reject) => {media[mime.startsWith('video') ? 'onloadedmetadata' : 'onload'] = resolve; media.onerror = reject;});
|
||||
media.src = `data:${mime};base64,${data}`; await loaded;
|
||||
return [media.videoWidth || media.naturalWidth, media.videoHeight || media.naturalHeight];
|
||||
}, {data: bytes.toString('base64'), mime});
|
||||
assert(dimensions.every(n => n > 0), format);
|
||||
}
|
||||
exports.push({format, path, bytes:bytes.length});
|
||||
}
|
||||
const opener = panel.getByRole('button', {name:/^Expand /});
|
||||
await opener.click();
|
||||
let full = await (await panel.locator('dialog iframe').elementHandle()).contentFrame();
|
||||
await full.waitForFunction(() => document.documentElement.dataset.theme === 'dark');
|
||||
await full.locator('#btn-theme').click(); await theme('light');
|
||||
const close = panel.getByRole('button', {name:'Close full-screen diagram'});
|
||||
assert.equal(await close.innerText(), '×');
|
||||
await close.click(); assert(await opener.evaluate(n => n === document.activeElement));
|
||||
await opener.click();
|
||||
full = await (await panel.locator('dialog iframe').elementHandle()).contentFrame();
|
||||
await full.waitForFunction(() => document.documentElement.dataset.theme === 'light');
|
||||
await close.focus(); await page.keyboard.press('Escape');
|
||||
assert(await opener.evaluate(n => n === document.activeElement));
|
||||
await iframe.scrollIntoViewIfNeeded();
|
||||
await page.evaluate(() => scrollTo(0, 0));
|
||||
await page.screenshot({path:`${output}/${width}.png`, fullPage:true});
|
||||
await frame.locator('#btn-theme').click(); await theme('dark');
|
||||
await page.locator('.strategy-content a[href$="/strategy/target-operating-model/"]').filter({visible:true}).click();
|
||||
const next = page.locator(`.archify-panel--story[data-diagram="strategy-target"] > .story-${width > 700 ? 'desktop' : 'mobile'}`);
|
||||
await next.scrollIntoViewIfNeeded();
|
||||
await (await (await next.elementHandle()).contentFrame()).waitForFunction(() => document.documentElement.dataset.theme === 'dark');
|
||||
assert(!await page.evaluate(() => document.documentElement.scrollWidth > innerWidth));
|
||||
results.push({width, themeSync:true, presets:4, exports, fullscreen:true, routeTheme:true});
|
||||
fs.writeFileSync(`${output}/results.json`, JSON.stringify(results,null,2));
|
||||
await context.close();
|
||||
}
|
||||
console.log(`PASS ${results.length} viewports: bidirectional themes, 4 presets, all supported exports decoded, fullscreen and route persistence`);
|
||||
} finally {await browser.close();}
|
||||
})().catch(error => {console.error(error); process.exitCode = 1;});
|
||||
@@ -1,8 +1,10 @@
|
||||
import {readdirSync, readFileSync, mkdirSync, copyFileSync, renameSync, rmSync} from 'node:fs';
|
||||
import {readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, renameSync, rmSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
const cli = 'vendor/archify/bin/archify.mjs';
|
||||
const types = new Set(['architecture','workflow','sequence','dataflow','lifecycle']);
|
||||
const inlineStyles = readFileSync(new URL('./inline-viewer.css', import.meta.url), 'utf8');
|
||||
const themeBridge = readFileSync(new URL('./viewer-theme-bridge.js', import.meta.url), 'utf8');
|
||||
// This directory is generated-only and ignored by Git. Remove stale artifacts.
|
||||
rmSync('static/diagrams', {recursive: true, force: true});
|
||||
mkdirSync('static/diagrams', {recursive: true});
|
||||
@@ -23,6 +25,19 @@ for (const file of readdirSync('diagrams').sort()) {
|
||||
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.
|
||||
let viewer = readFileSync(output, 'utf8');
|
||||
// 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 () {');
|
||||
const end = viewer.indexOf('Archify.theme = (function () {', start);
|
||||
const preset = viewer.slice(start, end);
|
||||
const guard = "if (html.getAttribute('data-embed') === 'true') return false;";
|
||||
if (start < 0 || end < start || preset.split(guard).length !== 3) throw new Error('Archify preset integration drift');
|
||||
viewer = viewer.slice(0, start) + preset.replaceAll(guard, '') + viewer.slice(end);
|
||||
if (!viewer.includes('</head>') || !viewer.includes('</body>')) throw new Error(`Invalid viewer: ${output}`);
|
||||
writeFileSync(output, viewer.replace('</head>', `<style id="eldorado-inline-controls">${inlineStyles}</style></head>`)
|
||||
.replace('</body>', `<script>${themeBridge}</script></body>`));
|
||||
renameSync(`static/diagrams/${name}/index.delivery.json`, `.cache/archify/${name}.delivery.json`);
|
||||
}
|
||||
copyFileSync('vendor/archify/LICENSE','static/diagrams/ARCHIFY-LICENSE.txt');
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Portal integration only; keep the upstream renderer and export code pinned. */
|
||||
html[data-embed="true"] .toolbar {
|
||||
display: flex !important;
|
||||
position: relative;
|
||||
inset: auto;
|
||||
justify-content: center;
|
||||
height: 52px;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg);
|
||||
}
|
||||
html[data-embed="true"] #btn-present,
|
||||
html[data-embed="true"] #btn-motion { display: none !important; }
|
||||
html[data-embed="true"] .diagram-container > svg {
|
||||
max-height: calc(100vh - 68px);
|
||||
}
|
||||
html[data-embed="true"] .toolbar .preset-menu,
|
||||
html[data-embed="true"] .toolbar .export-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: auto;
|
||||
width: min(320px, 100vw);
|
||||
max-width: 100vw;
|
||||
max-height: calc(100vh - 52px);
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html[data-embed="true"] .toolbar .export-wrap { position: static; }
|
||||
html[data-embed="true"] .toolbar .export-menu { left: auto; right: 0; }
|
||||
html[data-embed="true"] .toolbar .preset-menu button,
|
||||
html[data-embed="true"] .toolbar .export-menu button { min-height: 44px; }
|
||||
@@ -0,0 +1,51 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {runInNewContext} from 'node:vm';
|
||||
const read = path => readFileSync(path, 'utf8');
|
||||
|
||||
test('both story layouts permit downloads without weakening origin isolation', () => {
|
||||
const wrapper = read('src/components/ArchifyDiagram/index.tsx');
|
||||
for (const layout of ['desktop', 'mobile']) {
|
||||
const iframe = wrapper.split(`<iframe className="story-${layout}"`)[1].split(' />')[0];
|
||||
assert(iframe.includes('sandbox="allow-scripts allow-downloads"'));
|
||||
assert(iframe.includes('?embed=1&theme=${initialTheme.current}'));
|
||||
}
|
||||
assert(!wrapper.includes('allow-same-origin'));
|
||||
});
|
||||
|
||||
test('build adds maintained embed-only controls without changing pinned vendor', () => {
|
||||
const build = read('scripts/build-diagrams.mjs');
|
||||
assert(build.includes("new URL('./inline-viewer.css', import.meta.url)"));
|
||||
assert(build.includes('eldorado-inline-controls'));
|
||||
const css = read('scripts/inline-viewer.css');
|
||||
assert(css.includes('html[data-embed="true"] .toolbar'));
|
||||
assert(css.includes('display: flex !important'));
|
||||
assert(css.includes('position: relative'));
|
||||
assert(css.includes('max-height: calc(100vh - 52px)'));
|
||||
});
|
||||
|
||||
test('theme bridge rejects unrelated senders/values and never echoes parent updates', () => {
|
||||
const root = {dataset:{theme:'light'}};
|
||||
let receive, observerCallback, mutations = 0;
|
||||
const messages = [];
|
||||
const parent = {postMessage: message => messages.push(message)};
|
||||
const window = {parent, addEventListener: (type, callback) => {receive = callback;}};
|
||||
const button = {click: () => {root.dataset.theme = root.dataset.theme === 'light' ? 'dark' : 'light'; mutations++;}};
|
||||
class MutationObserver {
|
||||
constructor(callback) {observerCallback = callback;}
|
||||
observe() {}
|
||||
takeRecords() {mutations = 0;}
|
||||
}
|
||||
runInNewContext(read('scripts/viewer-theme-bridge.js'), {window, document:{documentElement:root, getElementById: () => button}, MutationObserver});
|
||||
const send = (source, theme) => receive({source, data:{type:'eldorado:set-theme', theme}});
|
||||
send({}, 'dark'); send(parent, 'invalid');
|
||||
assert.equal(root.dataset.theme, 'light');
|
||||
send(parent, 'dark');
|
||||
assert.equal(root.dataset.theme, 'dark');
|
||||
assert.equal(mutations, 0);
|
||||
assert.equal(messages.length, 0);
|
||||
button.click(); observerCallback();
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].theme, 'light');
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// Sandboxed viewers have opaque origins: authenticate the sender by WindowProxy,
|
||||
// not origin. Only exchange a validated theme, never HTML, URLs or commands.
|
||||
(() => {
|
||||
if (window.parent === window) return;
|
||||
let connected = false;
|
||||
window.addEventListener('message', event => {
|
||||
if (event.source !== window.parent || event.data?.type !== 'eldorado:set-theme') return;
|
||||
const theme = event.data.theme;
|
||||
if (theme !== 'light' && theme !== 'dark') return;
|
||||
connected = true;
|
||||
if (document.documentElement.dataset.theme !== theme) {
|
||||
document.getElementById('btn-theme').click();
|
||||
// Parent updates are not new user choices; do not echo stale acknowledgements.
|
||||
observer.takeRecords();
|
||||
}
|
||||
});
|
||||
const observer = new MutationObserver(() => {
|
||||
if (connected) window.parent.postMessage({type: 'eldorado:theme', theme: document.documentElement.dataset.theme}, '*');
|
||||
});
|
||||
observer.observe(document.documentElement, {attributes: true, attributeFilter: ['data-theme']});
|
||||
})();
|
||||
Reference in new issue
Block a user