fix: unify landing glyph animation with pinned site fonts
Reference site / build (push) Canceled after 0s

This commit is contained in:
2026-10-11 01:08:38 +09:00
parent 39e1565cb9
commit ef5ea5e528
28 changed files with 1046 additions and 198 deletions
+91
View File
@@ -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.');
});
+270 -61
View File
@@ -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('./');
+8 -5
View File
@@ -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('通信の約束まで');
+50
View File
@@ -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/);
});