fix: unify landing glyph animation with pinned site fonts
Reference site / build (push) Canceled after 0s
Reference site / build (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.use({ javaScriptEnabled: true });
|
||||
|
||||
test('self-hosted Lyth Mono and EB Garamond load with the configured base', async ({ page }, testInfo) => {
|
||||
const external: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.resourceType() === 'font' && !request.url().startsWith(testInfo.project.use.baseURL!)) external.push(request.url());
|
||||
});
|
||||
await page.goto('./');
|
||||
const fonts = await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const mono = await document.fonts.load('14px "Lyth Mono"', '0W│├└─');
|
||||
const bold = await document.fonts.load('700 14px "Lyth Mono"', 'WIP');
|
||||
const serif = await document.fonts.load('16px "EB Garamond"', 'Worldspace');
|
||||
const italic = await document.fonts.load('italic 16px "EB Garamond"', 'Worldspace');
|
||||
return {
|
||||
mono: mono.map((font) => font.status), bold: bold.map((font) => font.status),
|
||||
serif: serif.map((font) => font.status), italic: italic.map((font) => font.status),
|
||||
stack: getComputedStyle(document.body).fontFamily,
|
||||
prefix: document.querySelector('.branch-prefix')!.textContent!.trim(),
|
||||
};
|
||||
});
|
||||
expect(fonts.mono).toEqual(['loaded']);
|
||||
expect(fonts.bold).toEqual(['loaded']);
|
||||
expect(fonts.serif).toEqual(['loaded']);
|
||||
expect(fonts.italic).toEqual(['loaded']);
|
||||
expect(fonts.stack).toContain('Lyth Mono');
|
||||
expect(fonts.prefix).toBe(''); // SSR uses the same glyph paths, not HTML wires.
|
||||
expect(external).toEqual([]);
|
||||
await page.goto('./book/1-concept/');
|
||||
expect(await page.locator('.prose').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond');
|
||||
await page.screenshot({ path: testInfo.outputPath('eb-garamond-book.png') });
|
||||
});
|
||||
|
||||
test.describe('static font and tree rendering', () => {
|
||||
test.use({ javaScriptEnabled: false, deviceScaleFactor: 4 });
|
||||
test('glyph joins paint continuously across SVG cell boundaries', async ({ page }) => {
|
||||
await page.goto('./');
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const joins = await page.locator('.demo-node[aria-level="2"]').evaluateAll((nodes) => nodes.map((node) => {
|
||||
const svg = node.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
|
||||
const p = new DOMPoint(Number(svg.dataset.cellWidth) / 2, 12).matrixTransform(svg.getScreenCTM()!);
|
||||
return { x: p.x, y: p.y };
|
||||
}));
|
||||
for (const join of joins) {
|
||||
const clip = { x: Math.floor(join.x), y: Math.floor(join.y) - 1, width: 14, height: 3 };
|
||||
const png = (await page.screenshot({ clip })).toString('base64');
|
||||
const columns = await page.evaluate(async (png) => {
|
||||
const image = new Image(); image.src = 'data:image/png;base64,' + png;
|
||||
await image.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width; canvas.height = image.height;
|
||||
const ctx = canvas.getContext('2d')!; ctx.drawImage(image, 0, 0);
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
return Array.from({ length: canvas.width }, (_, x) => {
|
||||
let darkest = 765;
|
||||
for (let y = 0; y < canvas.height; y++) {
|
||||
const i = (y * canvas.width + x) * 4;
|
||||
darkest = Math.min(darkest, data[i] + data[i + 1] + data[i + 2]);
|
||||
}
|
||||
return darkest;
|
||||
});
|
||||
}, png);
|
||||
// Ignore only the outer antialiasing fringe at the trunk's left edge.
|
||||
const start = Math.ceil((join.x - clip.x + .25) * 4);
|
||||
expect(columns.slice(start).every((ink) => ink < 710), JSON.stringify(columns)).toBe(true);
|
||||
}
|
||||
});
|
||||
test('the same SVG trunk and Lyth Mono joins are visible without JavaScript', async ({ page }, testInfo) => {
|
||||
await page.goto('./');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
await expect(page.locator('.trunk-line')).toBeVisible();
|
||||
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
|
||||
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── ');
|
||||
await expect(page.locator('.branch-prefix').first().locator('path')).toHaveCount(2);
|
||||
await expect(page.getByRole('link', { name: 'Book', exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath('lyth-mono-static.png') });
|
||||
});
|
||||
});
|
||||
|
||||
test('failed font downloads do not strand the animation or interactive demo', async ({ page }) => {
|
||||
await page.route('**/*.woff2', (route) => route.abort());
|
||||
await page.goto('./');
|
||||
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'armed');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete');
|
||||
await page.getByRole('button', { name: 'Call', exact: false }).click();
|
||||
await expect(page.locator('[data-result]')).toContainText('Give every capability a place.');
|
||||
});
|
||||
@@ -3,6 +3,35 @@ import { expect, test } from '@playwright/test';
|
||||
test.use({ javaScriptEnabled: true });
|
||||
|
||||
const stage = '[data-landing-stage]';
|
||||
// Filled glyph bounds describe the ink; the anchor describes its origin.
|
||||
// Path length/start points describe an outline perimeter, not the trunk axis.
|
||||
async function trunkGeometry(page: import('@playwright/test').Page) {
|
||||
return page.evaluate(() => {
|
||||
const path = document.querySelector<SVGPathElement>('.trunk-line')!;
|
||||
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
|
||||
const reveal = document.querySelector<SVGRectElement>('.trunk-reveal')!;
|
||||
const bounds = path.getBBox();
|
||||
const matrix = path.getScreenCTM()!;
|
||||
const anchorMatrix = anchor.getScreenCTM()!;
|
||||
const start = new DOMPoint(0, 0).matrixTransform(anchorMatrix);
|
||||
const tip = new DOMPoint(0, reveal.height.baseVal.value).matrixTransform(anchorMatrix);
|
||||
const slash = document.querySelector('.root-shape')!.getBoundingClientRect();
|
||||
const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGSVGElement>('.root-vector')!.getScreenCTM()!);
|
||||
return {
|
||||
width: bounds.width * Math.hypot(matrix.a, matrix.b),
|
||||
length: reveal.height.baseVal.value,
|
||||
opacity: Number(getComputedStyle(path).opacity),
|
||||
stemWidth: Number(anchor.dataset.stemWidth),
|
||||
slashHeight: slash.height,
|
||||
anchorX: (start.x - slash.left) / slash.width,
|
||||
gapRatio: (start.y - slash.bottom) / slash.height,
|
||||
dx: tip.x - start.x, dy: tip.y - start.y,
|
||||
axis: start.x - foot.x,
|
||||
gap: start.y - document.querySelector('.root-mark')!.getBoundingClientRect().bottom,
|
||||
top: start.y, bottom: tip.y, x: start.x,
|
||||
};
|
||||
});
|
||||
}
|
||||
async function armed(page: import('@playwright/test').Page) {
|
||||
await page.goto('./');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
@@ -35,6 +64,8 @@ for (const trigger of ['click', 'wheel', 'key']) {
|
||||
}
|
||||
|
||||
test('hover streams are masked at the actual slash edges throughout the loop', async ({ page }, testInfo) => {
|
||||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||||
await armed(page);
|
||||
const root = page.locator('.root-mark');
|
||||
const box = (await root.boundingBox())!;
|
||||
@@ -50,13 +81,13 @@ test('hover streams are masked at the actual slash edges throughout the loop', a
|
||||
});
|
||||
expect(mask.top).toBeCloseTo(shape.y, 1);
|
||||
expect(mask.bottom).toBeCloseTo(shape.y + shape.height, 1);
|
||||
for (const time of [0, 700, 1399]) {
|
||||
await page.locator('.tap-stream text').evaluateAll((texts, time) => {
|
||||
texts.forEach((text) => text.getAnimations().forEach((animation) => {
|
||||
animation.pause();
|
||||
animation.currentTime = time;
|
||||
}));
|
||||
}, time);
|
||||
let previousTime = 0;
|
||||
const transforms: string[][] = [];
|
||||
for (const time of [16, 700, 1399, 1416, 2100, 2799]) {
|
||||
// Anime.js advances on requestAnimationFrame, not Element.getAnimations().
|
||||
await page.clock.runFor(time - previousTime);
|
||||
transforms.push(await page.locator('.tap-stream text').evaluateAll((texts) => texts.map((text) => getComputedStyle(text).transform)));
|
||||
previousTime = time;
|
||||
const after = (await page.screenshot({ clip, path: testInfo.outputPath(`hover-mask-${time}.png`) })).toString('base64');
|
||||
const ink = await page.evaluate(async ({ before, after }) => {
|
||||
const pixels = async (base64: string) => {
|
||||
@@ -83,12 +114,64 @@ test('hover streams are masked at the actual slash edges throughout the loop', a
|
||||
expect(ink.minY + clip.y).toBeGreaterThanOrEqual(Math.floor(shape.y));
|
||||
expect(ink.maxY + clip.y).toBeLessThanOrEqual(Math.ceil(shape.y + shape.height));
|
||||
}
|
||||
for (let stream = 0; stream < 2; stream++) {
|
||||
expect(transforms[1][stream]).not.toBe(transforms[0][stream]);
|
||||
expect(transforms[2][stream]).not.toBe(transforms[1][stream]);
|
||||
expect(transforms[4][stream]).not.toBe(transforms[3][stream]);
|
||||
}
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0');
|
||||
await expect(page.locator('.tap-stream-b')).toHaveCSS('opacity', '0');
|
||||
});
|
||||
|
||||
test.describe('persistent vertical trunk', () => {
|
||||
test.use({ deviceScaleFactor: 4 });
|
||||
|
||||
test('painted top stays below the slash and at the SVG origin through completion', async ({ page }, testInfo) => {
|
||||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||||
await armed(page);
|
||||
await page.keyboard.press('Enter');
|
||||
// Isolate the line ink: the slash could otherwise occlude a glyph extending
|
||||
// upward behind it. Visibility does not change geometry or the timeline.
|
||||
await page.locator('.root-shape').evaluate((shape) => { (shape as SVGElement).style.visibility = 'hidden'; });
|
||||
let previous = 0;
|
||||
for (const time of [2440, 2600, 2800, 4000]) {
|
||||
await page.clock.runFor(time - previous);
|
||||
const points = await page.locator('.root-vector').evaluate((svg) => {
|
||||
const matrix = (svg as SVGSVGElement).getScreenCTM()!;
|
||||
const origin = new DOMPoint(12, 64).matrixTransform(matrix);
|
||||
const foot = new DOMPoint(12, 60).matrixTransform(matrix);
|
||||
return { x: origin.x, y: origin.y, footY: foot.y };
|
||||
});
|
||||
const clip = { x: Math.floor(points.x) - 2, y: Math.floor(points.y) - 8, width: 5, height: 20 };
|
||||
const png = (await page.screenshot({ clip, path: testInfo.outputPath(`origin-${time}.png`) })).toString('base64');
|
||||
const top = await page.evaluate(async (png) => {
|
||||
const image = new Image(); image.src = 'data:image/png;base64,' + png;
|
||||
await image.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width; canvas.height = image.height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const data = ctx.getImageData(0, 0, image.width, image.height).data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i] + data[i + 1] + data[i + 2] < 690) return Math.floor(i / 4 / image.width) / devicePixelRatio;
|
||||
}
|
||||
return null;
|
||||
}, png);
|
||||
expect(top).not.toBeNull();
|
||||
const paintedTop = clip.y + top!;
|
||||
expect.soft(paintedTop, `line top below slash at ${time}ms`).toBeGreaterThan(points.footY);
|
||||
expect.soft(Math.abs(paintedTop - points.y), `line top matches origin at ${time}ms`).toBeLessThan(.3);
|
||||
previous = time;
|
||||
}
|
||||
await complete(page);
|
||||
await page.locator('.root-shape').evaluate((shape) => { (shape as SVGElement).style.removeProperty('visibility'); });
|
||||
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('completed-origin.png') });
|
||||
});
|
||||
});
|
||||
|
||||
test('completed tree aligns the slash foot with rendered box-drawing ink', async ({ page }, testInfo) => {
|
||||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||||
@@ -134,28 +217,18 @@ test('trunk thickness and origin stay proportional to the slash throughout scali
|
||||
let previousWidth = Infinity;
|
||||
for (const time of [1360, 1520, 1760, 2000, 2240, 2480, 2700]) {
|
||||
await page.clock.runFor(time - previousTime);
|
||||
const ink = await page.evaluate(() => {
|
||||
const path = document.querySelector<SVGPathElement>('.trunk-line')!;
|
||||
const matrix = path.getScreenCTM()!;
|
||||
const stroke = parseFloat(getComputedStyle(path).strokeWidth) * Math.hypot(matrix.a, matrix.b);
|
||||
const slash = document.querySelector('.root-shape')!.getBoundingClientRect();
|
||||
const start = path.getPointAtLength(0).matrixTransform(matrix);
|
||||
return {
|
||||
stroke, slashHeight: slash.height, opacity: Number(getComputedStyle(path).opacity),
|
||||
anchorX: (start.x - slash.left) / slash.width,
|
||||
gapRatio: (start.y - slash.bottom) / slash.height,
|
||||
};
|
||||
});
|
||||
const ink = await trunkGeometry(page);
|
||||
expect(ink.opacity).toBeGreaterThan(0);
|
||||
// At rest: 1px trunk, 21px of actual slash ink (56/64 of its 24px box).
|
||||
expect(ink.stroke / ink.slashHeight).toBeCloseTo(1 / 21, 5);
|
||||
// At rest: the Lyth Mono filled stem is 1.064px, slash ink is 21px.
|
||||
expect(ink.stemWidth).toBeCloseTo(1.064, 5);
|
||||
expect(ink.width / ink.slashHeight).toBeCloseTo(ink.stemWidth / 21, 5);
|
||||
// The foot is at x=12 within ink bounds x=7…41, not their centre.
|
||||
expect(ink.anchorX).toBeCloseTo(5 / 34, 5);
|
||||
expect(ink.gapRatio).toBeCloseTo(1 / 14, 5);
|
||||
expect(ink.stroke).toBeLessThan(previousWidth);
|
||||
expect(ink.width).toBeLessThan(previousWidth);
|
||||
if ([1520, 2000, 2480].includes(time)) await page.screenshot({ path: testInfo.outputPath(`proportions-${time}.png`) });
|
||||
previousTime = time;
|
||||
previousWidth = ink.stroke;
|
||||
previousWidth = ink.width;
|
||||
}
|
||||
await page.clock.runFor(1400);
|
||||
await complete(page);
|
||||
@@ -215,56 +288,56 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
|
||||
expect(tangent.dy).toBeCloseTo(0, 1);
|
||||
expect(tangent.y).toBeCloseTo(-180, 1);
|
||||
expect(Number(await page.locator('.orbit textPath').getAttribute('startOffset'))).toBeGreaterThan(arcLength / 2 + 100);
|
||||
const earlyLength = await page.locator('.trunk-line').evaluate((path) => (path as SVGPathElement).getTotalLength());
|
||||
const early = await trunkGeometry(page);
|
||||
const earlyLength = early.length;
|
||||
const earlyRoot = (await root.boundingBox())!;
|
||||
expect(earlyLength).toBeGreaterThan(10);
|
||||
expect(early.dy).toBeGreaterThan(10);
|
||||
await page.screenshot({ path: testInfo.outputPath('04-release-growth.png') });
|
||||
await page.clock.runFor(260);
|
||||
const extendedLength = await page.locator('.trunk-line').evaluate((path) => (path as SVGPathElement).getTotalLength());
|
||||
const extended = await trunkGeometry(page);
|
||||
const extendedLength = extended.length;
|
||||
const extendedRoot = (await root.boundingBox())!;
|
||||
expect(extendedLength).toBeGreaterThan(earlyLength * 1.4);
|
||||
expect(extendedLength).toBeGreaterThan(page.viewportSize()!.height * .15);
|
||||
expect(extended.dy).toBeGreaterThan(page.viewportSize()!.height * .15);
|
||||
// The trunk grows WHILE the root travels and shrinks, never after a hold.
|
||||
expect(extendedRoot.height).toBeLessThan(earlyRoot.height - 5);
|
||||
expect(extendedRoot.x + extendedRoot.width / 2).toBeLessThan(earlyRoot.x + earlyRoot.width / 2 - 2);
|
||||
await expect(page.locator('.demo-node:not(.root-mark)').first()).toHaveCSS('opacity', '0');
|
||||
const extended = await page.evaluate(() => {
|
||||
const path = document.querySelector<SVGPathElement>('.trunk-line')!;
|
||||
const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!);
|
||||
const tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!);
|
||||
const root = document.querySelector('.root-mark')!.getBoundingClientRect();
|
||||
const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGSVGElement>('.root-vector')!.getScreenCTM()!);
|
||||
return { dx: tip.x - start.x, dy: tip.y - start.y, axis: start.x - foot.x, gap: start.y - root.bottom, bottom: tip.y };
|
||||
});
|
||||
expect(Math.abs(extended.dx)).toBeLessThan(.1);
|
||||
expect(Math.abs(extended.axis)).toBeLessThan(.1);
|
||||
expect(extended.dy).toBeGreaterThan(0);
|
||||
expect(extended.gap).toBeCloseTo(0, 1);
|
||||
expect(extended.bottom).toBeLessThan(page.viewportSize()!.height);
|
||||
await page.screenshot({ path: testInfo.outputPath('04-travelling-trunk.png') });
|
||||
await page.clock.runFor(920); // 2600ms: moving tree, SVG handoff AND panel onset.
|
||||
const handoff = await page.evaluate(() => {
|
||||
const path = document.querySelector<SVGPathElement>('.trunk-line')!;
|
||||
const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!);
|
||||
const tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!);
|
||||
const first = document.querySelector('.branch-spine')!.getBoundingClientRect();
|
||||
const last = [...document.querySelectorAll('.demo-node[aria-level="2"]')].at(-1)!.parentElement!.querySelector('.branch-spine')!.getBoundingClientRect();
|
||||
await page.clock.runFor(920); // 2600ms: moving shared scene, persistent trunk AND panel onset.
|
||||
const overlap = await page.evaluate(() => {
|
||||
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
|
||||
const matrix = anchor.getScreenCTM()!;
|
||||
const start = new DOMPoint(0, 0).matrixTransform(matrix);
|
||||
const tip = new DOMPoint(0, document.querySelector<SVGRectElement>('.trunk-reveal')!.height.baseVal.value).matrixTransform(matrix);
|
||||
const first = document.querySelector<SVGSVGElement>('.branch-vector')!;
|
||||
const last = [...document.querySelectorAll('.demo-node[aria-level="2"]')].at(-1)!.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
|
||||
const join = new DOMPoint(Number(last.dataset.cellWidth) / 2, Number(anchor.dataset.branchY) + Number(anchor.dataset.branchThickness) / 2).matrixTransform(last.getScreenCTM()!);
|
||||
const firstTop = new DOMPoint(Number(first.dataset.cellWidth) / 2, 0).matrixTransform(first.getScreenCTM()!);
|
||||
return {
|
||||
start: Math.hypot(start.x - (first.left + first.width / 2), start.y - first.top),
|
||||
end: Math.hypot(tip.x - (last.left + last.width / 2), tip.y - (last.top + last.height / 2)),
|
||||
spineOpacity: Number(getComputedStyle(document.querySelector('.branch-spine')!).opacity),
|
||||
trunkOpacity: Number(getComputedStyle(path).opacity),
|
||||
start: Math.hypot(start.x - firstTop.x, start.y - firstTop.y),
|
||||
end: Math.hypot(tip.x - join.x, tip.y - join.y),
|
||||
spineOpacity: Number(getComputedStyle(first.querySelector('.branch-spine')!).opacity),
|
||||
trunkOpacity: Number(getComputedStyle(document.querySelector('.trunk-line')!).opacity),
|
||||
labelOpacity: Number(getComputedStyle(document.querySelector('.demo-node:not(.root-mark)')!).opacity),
|
||||
panelOpacity: Number(getComputedStyle(document.querySelector('.text-panel')!).opacity),
|
||||
sceneTransform: getComputedStyle(document.querySelector('.tree-scene')!).transform,
|
||||
rowTransforms: [...document.querySelectorAll('.node-row, .demo-node')].map((row) => getComputedStyle(row).transform),
|
||||
};
|
||||
});
|
||||
expect(handoff.start).toBeLessThan(1);
|
||||
expect(handoff.end).toBeLessThan(1);
|
||||
expect(handoff.spineOpacity).toBeGreaterThan(0);
|
||||
expect(handoff.trunkOpacity).toBeGreaterThan(0);
|
||||
expect(handoff.spineOpacity + handoff.trunkOpacity).toBeCloseTo(1, 4);
|
||||
expect(handoff.labelOpacity).toBeGreaterThan(0);
|
||||
expect(handoff.panelOpacity).toBeGreaterThan(0);
|
||||
expect(overlap.start).toBeLessThan(1);
|
||||
expect(overlap.end).toBeLessThan(1);
|
||||
expect(overlap.spineOpacity).toBeGreaterThan(0);
|
||||
expect(overlap.trunkOpacity).toBe(1); // No opacity handoff or replacement text spine.
|
||||
expect(overlap.labelOpacity).toBeGreaterThan(0);
|
||||
expect(overlap.panelOpacity).toBeGreaterThan(0);
|
||||
expect(overlap.sceneTransform).not.toBe('none');
|
||||
expect(overlap.rowTransforms.every((transform) => transform === 'none')).toBe(true);
|
||||
const growingTreeRoot = (await root.boundingBox())!;
|
||||
expect(growingTreeRoot.height).toBeGreaterThan(24.1);
|
||||
await page.screenshot({ path: testInfo.outputPath('05-moving-tree.png') });
|
||||
@@ -292,15 +365,17 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
|
||||
for (const side of await panel.locator('.box-column').all()) await expect(side).toHaveText(/^[│\s]+$/);
|
||||
}
|
||||
await expect(page.locator('.branch-lines')).toHaveCount(0);
|
||||
await expect(page.locator('.branch-prefix')).toHaveText([
|
||||
expect(await page.locator('.branch-prefix').evaluateAll((prefixes) => prefixes.map((prefix) => prefix.getAttribute('data-prefix')))).toEqual([
|
||||
'├── ', '│ ├── ', '│ └── ', '│ └── ',
|
||||
'├── ', '│ └── ', '└── ', ' └── ',
|
||||
]);
|
||||
await expect(page.locator('.root-wire')).toHaveCount(0);
|
||||
await expect(page.locator('.branch-spine')).toHaveText(['├', '│', '│', '│', '├', '│', '└', ' ']);
|
||||
await expect(page.locator('.branch-prefix svg')).toHaveCount(8);
|
||||
await expect(page.locator('.branch-spine path')).toHaveCount(3);
|
||||
await expect(page.locator('.branch-arm path')).toHaveCount(8);
|
||||
await expect(page.locator('.tree-scene')).toHaveCSS('transform', 'none');
|
||||
const grid = await page.evaluate(() => {
|
||||
const root = document.querySelector('.root-mark')!.getBoundingClientRect();
|
||||
const spine = document.querySelector('.branch-spine')!.getBoundingClientRect();
|
||||
const prefix = document.querySelector<HTMLElement>('.branch-prefix')!;
|
||||
const cell = prefix.getBoundingClientRect().width / 4;
|
||||
const frame = document.querySelector('.landing-content')!.getBoundingClientRect();
|
||||
@@ -308,8 +383,8 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
|
||||
const nodes = [...document.querySelectorAll<HTMLElement>('.demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect());
|
||||
const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGSVGElement>('.root-vector')!.getScreenCTM()!);
|
||||
return {
|
||||
rootColumnError: Math.abs(foot.x - spine.left - spine.width / 2),
|
||||
rootGap: Math.abs(root.bottom - spine.top),
|
||||
rootColumnError: Math.abs(foot.x - prefix.getBoundingClientRect().left - cell / 2),
|
||||
rootGap: Math.abs(root.bottom - prefix.getBoundingClientRect().top),
|
||||
frameCells: frame.width / cell,
|
||||
rowHeights: rows.map((row) => row.height),
|
||||
nodeColumns: nodes.map((node) => (node.left - frame.left) / cell),
|
||||
@@ -336,13 +411,14 @@ for (const time of [1500, 2600]) {
|
||||
await armed(page);
|
||||
await page.keyboard.press('a');
|
||||
await page.clock.runFor(time);
|
||||
await expect(page.locator('.node-row').last()).not.toHaveCSS('transform', 'none');
|
||||
await expect(page.locator('.tree-scene')).not.toHaveCSS('transform', 'none');
|
||||
await page.keyboard.press('Escape');
|
||||
await complete(page);
|
||||
await expect(page.locator('.tree-scene')).toHaveCSS('transform', 'none');
|
||||
for (const row of await page.locator('.node-row').all()) await expect(row).toHaveCSS('transform', 'none');
|
||||
await expect(page.locator('.root-mark')).toHaveCSS('transform', 'none');
|
||||
await expect(page.locator('.site-header')).toHaveCSS('opacity', '1');
|
||||
await expect(page.locator('.branch-prefix').first()).toHaveText('├── ');
|
||||
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── ');
|
||||
await expect(page.locator('.intro-art')).toBeHidden();
|
||||
await page.clock.runFor(4000);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
|
||||
@@ -393,7 +469,7 @@ test('tree selection, collapse and call results are keyboard accessible', async
|
||||
await expect(page.locator('.branch-spine').first()).toBeHidden();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.locator('.branch-spine').first()).toBeVisible();
|
||||
await expect(page.locator('.branch-prefix').first()).toHaveText('├── ');
|
||||
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── ');
|
||||
await page.keyboard.press('End');
|
||||
await expect(page.getByRole('treeitem', { name: 'protocol', exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
@@ -402,6 +478,139 @@ test('tree selection, collapse and call results are keyboard accessible', async
|
||||
await expect(page.locator('[data-result]')).toContainText('fetch_interface');
|
||||
});
|
||||
|
||||
test.describe('glyph trunk regressions', () => {
|
||||
test.use({ deviceScaleFactor: 4 });
|
||||
|
||||
test('one SVG glyph representation persists through growth and completion', async ({ page }) => {
|
||||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||||
await armed(page);
|
||||
const original = await page.locator('.trunk-line').elementHandle();
|
||||
const outline = await page.locator('.trunk-line').getAttribute('d');
|
||||
await page.keyboard.press('Enter');
|
||||
let previous = 0;
|
||||
for (const time of [1400, 2440, 2600, 2800, 4000]) {
|
||||
await page.clock.runFor(time - previous);
|
||||
await expect(page.locator('.trunk-line')).toHaveCount(1);
|
||||
expect(await original!.evaluate((path) => path === document.querySelector('.trunk-line'))).toBe(true);
|
||||
await expect(page.locator('.trunk-line')).toHaveAttribute('d', outline!);
|
||||
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
|
||||
await expect(page.locator('.root-vector .trunk-anchor .trunk-line')).toHaveCount(1);
|
||||
await expect(page.locator('.trunk-line').locator('..')).toHaveAttribute('mask', 'url(#trunk-growth-mask)');
|
||||
await expect(page.locator('.root-vector path')).toHaveCount(2); // Slash and trunk only.
|
||||
await expect(page.locator('.branch-prefix text, .branch-prefix span, .root-wire, .branch-lines')).toHaveCount(0);
|
||||
expect(await page.locator('.branch-prefix').evaluateAll((prefixes) => prefixes.every((prefix) => !prefix.textContent!.trim()))).toBe(true);
|
||||
// First-column outlines are clipped to horizontal joins: no second spine.
|
||||
const clips = await page.locator('.branch-vector clipPath rect').evaluateAll((rects) => rects.map((rect) => {
|
||||
const svg = rect.closest('svg') as SVGSVGElement;
|
||||
return { x: Number(rect.getAttribute('x')), width: Number(rect.getAttribute('width')), cell: Number(svg.dataset.cellWidth) };
|
||||
}));
|
||||
expect(clips.every(({ x, width, cell }) => Math.abs(x - cell / 2) < 1e-6 && Math.abs(width - cell / 2) < 1e-6)).toBe(true);
|
||||
previous = time;
|
||||
}
|
||||
await complete(page);
|
||||
});
|
||||
|
||||
test('collapse and re-expand keep the painted trunk aligned without excess spine', async ({ page }, testInfo) => {
|
||||
await armed(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await complete(page);
|
||||
const checkEnd = async (name: string) => {
|
||||
const trunk = await trunkGeometry(page);
|
||||
const join = await page.evaluate(() => {
|
||||
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
|
||||
const last = [...document.querySelectorAll<HTMLElement>('.demo-node[aria-level="2"]')].filter((node) => !node.parentElement!.hidden).at(-1);
|
||||
if (!last) return null;
|
||||
const svg = last.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
|
||||
const point = new DOMPoint(Number(svg.dataset.cellWidth) / 2, Number(anchor.dataset.branchY) + Number(anchor.dataset.branchThickness) / 2).matrixTransform(svg.getScreenCTM()!);
|
||||
return { x: point.x, y: point.y };
|
||||
});
|
||||
if (join) {
|
||||
expect(Math.abs(trunk.x - join.x)).toBeLessThan(.1);
|
||||
expect(Math.abs(trunk.bottom - join.y)).toBeLessThan(.1);
|
||||
} else {
|
||||
expect(trunk.length).toBe(0);
|
||||
}
|
||||
// Include the final painted pixel and the blank column underneath. This
|
||||
// detects a path extending beyond its reveal mask or a spare text spine.
|
||||
const clip = { x: Math.floor(trunk.x) - 2, y: Math.floor(trunk.bottom) - (join ? 2 : 0), width: 5, height: 28 };
|
||||
// The root's keyboard focus ring crosses this column when collapsed;
|
||||
// isolate tree ink without changing focus, geometry or glyph visibility.
|
||||
const png = (await page.screenshot({ clip, path: testInfo.outputPath(`collapse-${name}.png`), style: '.root-mark:focus-visible { outline: none !important; }' })).toString('base64');
|
||||
const pixels = await page.evaluate(async (png) => {
|
||||
const image = new Image(); image.src = 'data:image/png;base64,' + png;
|
||||
await image.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width; canvas.height = image.height;
|
||||
const ctx = canvas.getContext('2d')!; ctx.drawImage(image, 0, 0);
|
||||
const data = ctx.getImageData(0, 0, image.width, image.height).data;
|
||||
let count = 0, bottom = -Infinity;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i] + data[i + 1] + data[i + 2] < 690) {
|
||||
count++; bottom = Math.max(bottom, (Math.floor(i / 4 / image.width) + 1) / devicePixelRatio);
|
||||
}
|
||||
}
|
||||
return { count, bottom };
|
||||
}, png);
|
||||
if (join) {
|
||||
expect(pixels.count).toBeGreaterThan(0);
|
||||
expect(Math.abs(clip.y + pixels.bottom - trunk.bottom)).toBeLessThan(.3);
|
||||
} else {
|
||||
expect(pixels.count).toBe(0);
|
||||
}
|
||||
return trunk.length;
|
||||
};
|
||||
let previous = await checkEnd('expanded');
|
||||
for (const name of ['repository', 'issues']) {
|
||||
const node = page.getByRole('treeitem', { name, exact: true });
|
||||
await node.focus();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(node).toHaveAttribute('aria-expanded', 'false');
|
||||
const length = await checkEnd(name);
|
||||
expect(length).toBeLessThan(previous);
|
||||
previous = length;
|
||||
}
|
||||
const knowledge = page.getByRole('treeitem', { name: 'knowledge', exact: true });
|
||||
await knowledge.focus();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await checkEnd('knowledge')).toBe(previous); // Last child never extends the first-column spine.
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(1);
|
||||
await checkEnd('root');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await checkEnd('root-reopened')).toBe(previous);
|
||||
for (const name of ['repository', 'issues', 'knowledge']) {
|
||||
await page.getByRole('treeitem', { name, exact: true }).focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
}
|
||||
expect(await checkEnd('all-reopened')).toBeGreaterThan(previous);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('JavaScript-free glyph tree', () => {
|
||||
test.use({ javaScriptEnabled: false });
|
||||
|
||||
test('SSR paints one filled trunk and SVG branch outlines without animation code', async ({ page }, testInfo) => {
|
||||
await page.goto('./');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
await expect(page.locator('.no-script-note')).toBeVisible();
|
||||
await expect(page.locator('.trunk-line')).toHaveCount(1);
|
||||
await expect(page.locator('.trunk-line')).toHaveCSS('opacity', '1');
|
||||
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
|
||||
await expect(page.locator('.branch-prefix svg')).toHaveCount(8);
|
||||
expect(await page.locator('.branch-prefix').evaluateAll((prefixes) => prefixes.every((prefix) => !prefix.textContent!.trim()))).toBe(true);
|
||||
const trunk = await trunkGeometry(page);
|
||||
expect(trunk.width).toBeCloseTo(1.064, 5);
|
||||
expect(trunk.length).toBeGreaterThan(0);
|
||||
expect(Math.abs(trunk.axis)).toBeLessThan(.1);
|
||||
expect(trunk.gapRatio).toBeCloseTo(1 / 14, 5);
|
||||
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('no-js-glyph-tree.png') });
|
||||
await page.getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで');
|
||||
});
|
||||
});
|
||||
|
||||
test('reduced motion opens the working demo immediately', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto('./');
|
||||
|
||||
@@ -6,14 +6,17 @@ test('home and book are usable without client JavaScript', async ({ page }, test
|
||||
await page.goto('./');
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('探索できる世界へ');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await expect(page.locator('.branch-prefix').first()).toHaveText('├── ');
|
||||
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── ');
|
||||
const root = await page.locator('.root-mark').boundingBox();
|
||||
const spine = await page.locator('.branch-spine').first().boundingBox();
|
||||
const spine = await page.locator('.branch-vector').first().evaluate((element) => {
|
||||
const svg = element as SVGSVGElement;
|
||||
const top = new DOMPoint(Number(svg.dataset.cellWidth) / 2, 0).matrixTransform(svg.getScreenCTM()!);
|
||||
return { x: top.x, y: top.y };
|
||||
});
|
||||
expect(root).not.toBeNull();
|
||||
expect(spine).not.toBeNull();
|
||||
const footX = await page.locator('.root-vector').evaluate((svg) => new DOMPoint(12, 60).matrixTransform((svg as SVGSVGElement).getScreenCTM()!).x);
|
||||
expect(Math.abs(footX - spine!.x - spine!.width / 2)).toBeLessThan(1);
|
||||
expect(Math.abs(root!.y + root!.height - spine!.y)).toBeLessThan(1);
|
||||
expect(Math.abs(footX - spine.x)).toBeLessThan(1);
|
||||
expect(Math.abs(root!.y + root!.height - spine.y)).toBeLessThan(1);
|
||||
await page.screenshot({ path: testInfo.outputPath('home.png'), fullPage: true });
|
||||
await page.getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで');
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import opentype from 'opentype.js';
|
||||
import { glyphPath, trunkPath, cellWidth, rowHeight, stemWidth, branchY, branchThickness } from '../src/lib/landing-glyphs.mjs';
|
||||
|
||||
const box = (d) => opentype.Path.fromSVG(d, { flipY: false, decimalPlaces: 5 }).getBoundingBox();
|
||||
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < .00002, `${actual} != ${expected}`);
|
||||
|
||||
test('pinned font binaries and upstream licenses match their integrity manifest', () => {
|
||||
const lines = readFileSync('public/fonts/SHA256SUMS', 'utf8').trim().split('\n');
|
||||
assert.ok(lines.length >= 8);
|
||||
for (const line of lines) {
|
||||
const [expected, name] = line.trim().split(/\s+/);
|
||||
const bytes = readFileSync(`public/fonts/${name}`);
|
||||
assert.equal(createHash('sha256').update(bytes).digest('hex'), expected, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('Lyth Mono paths retain exact advance, row bounds and stroke dimensions', () => {
|
||||
close(cellWidth, 8.456);
|
||||
close(stemWidth, 1.064);
|
||||
close(branchY, rowHeight / 2);
|
||||
const vertical = box(glyphPath('│'));
|
||||
close(vertical.y1, 0);
|
||||
close(vertical.y2, 24);
|
||||
close((vertical.x1 + vertical.x2) / 2, cellWidth / 2);
|
||||
close(vertical.x2 - vertical.x1, stemWidth);
|
||||
const horizontal = box(glyphPath('─'));
|
||||
close(horizontal.x1, 0);
|
||||
close(horizontal.x2, cellWidth);
|
||||
close(horizontal.y2 - horizontal.y1, branchThickness);
|
||||
const trunk = box(trunkPath);
|
||||
close(trunk.x1 + trunk.x2, 0);
|
||||
close(trunk.x2 - trunk.x1, stemWidth);
|
||||
close(trunk.y1, 0);
|
||||
close(trunk.y2, 1);
|
||||
});
|
||||
|
||||
test('cell layout bypasses shaping, preserves spaces and fails for missing glyphs', () => {
|
||||
close(box(glyphPath(' │')).x1, box(glyphPath('│')).x1 + 4 * cellWidth);
|
||||
close(box(glyphPath('── ')).x2, 2 * cellWidth);
|
||||
for (const prefix of ['├── ', '│ ├── ', '│ └── ', '│ └── ', ' └── ']) {
|
||||
const path = glyphPath(prefix);
|
||||
assert.ok(path.length > 0);
|
||||
assert.ok(!path.includes('NaN'));
|
||||
}
|
||||
assert.throws(() => glyphPath('日'), /Lyth Mono is missing/);
|
||||
});
|
||||
Reference in New Issue
Block a user