fix: align the slash foot with the rendered tree spine
Reference site / build (push) Canceled after 0s
Reference site / build (push) Canceled after 0s
This commit is contained in:
@@ -237,7 +237,7 @@ export function initLanding() {
|
|||||||
const target = slot.getBoundingClientRect();
|
const target = slot.getBoundingClientRect();
|
||||||
const treeTarget = tree.getBoundingClientRect();
|
const treeTarget = tree.getBoundingClientRect();
|
||||||
// offsetWidth rounds fractional mono cells and would shift the SVG axis.
|
// offsetWidth rounds fractional mono cells and would shift the SVG axis.
|
||||||
const endX = target.left + parseFloat(getComputedStyle(root).width) / 2;
|
const endX = target.left + parseFloat(getComputedStyle(slot).paddingLeft) + parseFloat(getComputedStyle(root).width) / 2;
|
||||||
const endY = target.bottom - root.offsetHeight / 2;
|
const endY = target.bottom - root.offsetHeight / 2;
|
||||||
const travel = timing.travel(progress(time, 0, 3000));
|
const travel = timing.travel(progress(time, 0, 3000));
|
||||||
const remaining = 1 - travel;
|
const remaining = 1 - travel;
|
||||||
@@ -263,13 +263,14 @@ export function initLanding() {
|
|||||||
const extension = timing.trace(progress(time, flow.grow, 1000));
|
const extension = timing.trace(progress(time, flow.grow, 1000));
|
||||||
const converge = timing.travel(progress(time, flow.converge, 560));
|
const converge = timing.travel(progress(time, flow.converge, 560));
|
||||||
const longLength = Math.max(0, innerHeight * .4 - root.offsetHeight * 7.2 / 2 - 8);
|
const longLength = Math.max(0, innerHeight * .4 - root.offsetHeight * 7.2 / 2 - 8);
|
||||||
// Anchor to the root's local bottom edge at every scale. Only the tip
|
// Follow the slash's actual foot (x=12), not its bounding-box centre.
|
||||||
// extends/converges; the origin has no independent screen-space offset.
|
// y=64 is the row boundary, a fixed four SVG units below the ink at y=60.
|
||||||
const lineStartY = centreY + root.offsetHeight * scale / 2;
|
const origin = new DOMPoint(12, 64).matrixTransform(get<SVGSVGElement>('.root-vector').getScreenCTM()!);
|
||||||
|
const lineStartY = origin.y;
|
||||||
const finalLength = lastRootBranch.offsetTop + lastRootBranch.offsetHeight / 2 - slot.offsetTop - slot.offsetHeight;
|
const finalLength = lastRootBranch.offsetTop + lastRootBranch.offsetHeight / 2 - slot.offsetTop - slot.offsetHeight;
|
||||||
const tipY = lineStartY + mix(longLength * extension, finalLength * scale, converge);
|
const tipY = lineStartY + mix(longLength * extension, finalLength * scale, converge);
|
||||||
const handoff = progress(time, flow.handoff, 280);
|
const handoff = progress(time, flow.handoff, 280);
|
||||||
trunk.setAttribute('d', `M${centreX} ${lineStartY} V${tipY}`);
|
trunk.setAttribute('d', `M${origin.x} ${lineStartY} V${tipY}`);
|
||||||
// Keep the 1px resting stroke proportional to the slash's shared scale.
|
// Keep the 1px resting stroke proportional to the slash's shared scale.
|
||||||
// Convergence changes the trunk's length, never its relative thickness.
|
// Convergence changes the trunk's length, never its relative thickness.
|
||||||
trunk.style.strokeWidth = String(scale);
|
trunk.style.strokeWidth = String(scale);
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ html[data-landing] .landing-page .site-header { opacity: 0; }
|
|||||||
.node-row { position: relative; padding-left: 1ch; height: var(--row); display: flex; align-items: center; white-space: nowrap; }
|
.node-row { position: relative; padding-left: 1ch; height: var(--row); display: flex; align-items: center; white-space: nowrap; }
|
||||||
.node-row[hidden] { display: none; }
|
.node-row[hidden] { display: none; }
|
||||||
.node-row:not(.root-slot) { transform-origin: 0 0; }
|
.node-row:not(.root-slot) { transform-origin: 0 0; }
|
||||||
.node-row.root-slot { padding: 0; height: calc(3 * var(--row)); align-items: end; }
|
/* The slash's lower edge is centred at x=12, not the viewBox centre x=24.
|
||||||
|
Offset its box by 12/64 of the row so the foot sits on the mono spine. */
|
||||||
|
.node-row.root-slot { padding: 0 0 0 calc(var(--row) * .1875); height: calc(3 * var(--row)); align-items: end; }
|
||||||
/* These are real Unicode box-drawing characters, not SVG paths or borders.
|
/* These are real Unicode box-drawing characters, not SVG paths or borders.
|
||||||
Scale only their vertical ink to cover the leading between mono baselines. */
|
Scale only their vertical ink to cover the leading between mono baselines. */
|
||||||
.branch-prefix { display: inline-flex; flex: 0 0 auto; white-space: pre; font: 14px/14px var(--mono); height: 14px; color: #8e9886; transform: scaleY(1.7142857143); transform-origin: center; pointer-events: none; }
|
.branch-prefix { display: inline-flex; flex: 0 0 auto; white-space: pre; font: 14px/14px var(--mono); height: 14px; color: #8e9886; transform: scaleY(1.7142857143); transform-origin: center; pointer-events: none; }
|
||||||
@@ -51,7 +53,7 @@ html[data-landing] .landing-page .site-header { opacity: 0; }
|
|||||||
.root-vector { display: block; width: 100%; height: 100%; overflow: visible; }
|
.root-vector { display: block; width: 100%; height: 100%; overflow: visible; }
|
||||||
.root-shape { fill: currentColor; }
|
.root-shape { fill: currentColor; }
|
||||||
.root-mark[aria-selected="true"] { color: #4e752d; }
|
.root-mark[aria-selected="true"] { color: #4e752d; }
|
||||||
.root-caption { margin-left: 2ch; height: var(--row); color: var(--muted); }
|
.root-caption { margin-left: calc(2ch - var(--row) * .1875); height: var(--row); color: var(--muted); }
|
||||||
.ascii-panel { position: relative; min-width: 0; padding: calc(2 * var(--row)) 2ch; }
|
.ascii-panel { position: relative; min-width: 0; padding: calc(2 * var(--row)) 2ch; }
|
||||||
.box-frame { position: absolute; inset: 0; pointer-events: none; user-select: none; color: #8e9886; }
|
.box-frame { position: absolute; inset: 0; pointer-events: none; user-select: none; color: #8e9886; }
|
||||||
.box-edge { position: absolute; inset-inline: 0; height: var(--row); display: grid; grid-template-columns: 1ch minmax(0, 1fr) 1ch; align-items: center; }
|
.box-edge { position: absolute; inset-inline: 0; height: var(--row); display: grid; grid-template-columns: 1ch minmax(0, 1fr) 1ch; align-items: center; }
|
||||||
|
|||||||
@@ -89,6 +89,42 @@ test('hover streams are masked at the actual slash edges throughout the loop', a
|
|||||||
await expect(page.locator('.tap-stream-b')).toHaveCSS('opacity', '0');
|
await expect(page.locator('.tap-stream-b')).toHaveCSS('opacity', '0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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'));
|
||||||
|
await armed(page);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.clock.runFor(4000);
|
||||||
|
await complete(page);
|
||||||
|
const foot = await page.locator('.root-vector').evaluate((svg) => {
|
||||||
|
const point = new DOMPoint(12, 60).matrixTransform((svg as SVGSVGElement).getScreenCTM()!);
|
||||||
|
return { x: point.x, y: point.y };
|
||||||
|
});
|
||||||
|
// Sample the painted vertical stroke, below the slash and above the first
|
||||||
|
// horizontal branch. Element boxes alone missed the original 4.5px error.
|
||||||
|
const clip = { x: Math.floor(foot.x) - 6, y: Math.ceil(foot.y) + 4, width: 13, height: 5 };
|
||||||
|
const png = (await page.screenshot({ clip })).toString('base64');
|
||||||
|
const ink = 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 weight = 0, moment = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const ink = Math.max(0, 240 - (data[i] + data[i + 1] + data[i + 2]) / 3);
|
||||||
|
weight += ink;
|
||||||
|
moment += (i / 4 % image.width + .5) * ink;
|
||||||
|
}
|
||||||
|
return { weight, x: moment / weight };
|
||||||
|
}, png);
|
||||||
|
expect(ink.weight).toBeGreaterThan(100);
|
||||||
|
expect(Math.abs(clip.x + ink.x - foot.x)).toBeLessThan(.75);
|
||||||
|
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('completed-tree.png') });
|
||||||
|
});
|
||||||
|
|
||||||
test('trunk thickness and origin stay proportional to the slash throughout scaling', async ({ page }, testInfo) => {
|
test('trunk thickness and origin stay proportional to the slash throughout scaling', async ({ page }, testInfo) => {
|
||||||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||||||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||||||
@@ -113,8 +149,8 @@ test('trunk thickness and origin stay proportional to the slash throughout scali
|
|||||||
expect(ink.opacity).toBeGreaterThan(0);
|
expect(ink.opacity).toBeGreaterThan(0);
|
||||||
// At rest: 1px trunk, 21px of actual slash ink (56/64 of its 24px box).
|
// At rest: 1px trunk, 21px of actual slash ink (56/64 of its 24px box).
|
||||||
expect(ink.stroke / ink.slashHeight).toBeCloseTo(1 / 21, 5);
|
expect(ink.stroke / ink.slashHeight).toBeCloseTo(1 / 21, 5);
|
||||||
// The same local anchor at every scale: centred, 1.5px below 21px of ink.
|
// The foot is at x=12 within ink bounds x=7…41, not their centre.
|
||||||
expect(ink.anchorX).toBeCloseTo(.5, 5);
|
expect(ink.anchorX).toBeCloseTo(5 / 34, 5);
|
||||||
expect(ink.gapRatio).toBeCloseTo(1 / 14, 5);
|
expect(ink.gapRatio).toBeCloseTo(1 / 14, 5);
|
||||||
expect(ink.stroke).toBeLessThan(previousWidth);
|
expect(ink.stroke).toBeLessThan(previousWidth);
|
||||||
if ([1520, 2000, 2480].includes(time)) await page.screenshot({ path: testInfo.outputPath(`proportions-${time}.png`) });
|
if ([1520, 2000, 2480].includes(time)) await page.screenshot({ path: testInfo.outputPath(`proportions-${time}.png`) });
|
||||||
@@ -197,7 +233,8 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
|
|||||||
const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!);
|
const start = path.getPointAtLength(0).matrixTransform(path.getScreenCTM()!);
|
||||||
const tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!);
|
const tip = path.getPointAtLength(path.getTotalLength()).matrixTransform(path.getScreenCTM()!);
|
||||||
const root = document.querySelector('.root-mark')!.getBoundingClientRect();
|
const root = document.querySelector('.root-mark')!.getBoundingClientRect();
|
||||||
return { dx: tip.x - start.x, dy: tip.y - start.y, axis: start.x - (root.left + root.width / 2), gap: start.y - root.bottom, bottom: tip.y };
|
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.dx)).toBeLessThan(.1);
|
||||||
expect(Math.abs(extended.axis)).toBeLessThan(.1);
|
expect(Math.abs(extended.axis)).toBeLessThan(.1);
|
||||||
@@ -269,8 +306,9 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
|
|||||||
const frame = document.querySelector('.landing-content')!.getBoundingClientRect();
|
const frame = document.querySelector('.landing-content')!.getBoundingClientRect();
|
||||||
const rows = [...document.querySelectorAll<HTMLElement>('.node-row:not(.root-slot)')].map((row) => row.getBoundingClientRect());
|
const rows = [...document.querySelectorAll<HTMLElement>('.node-row:not(.root-slot)')].map((row) => row.getBoundingClientRect());
|
||||||
const nodes = [...document.querySelectorAll<HTMLElement>('.demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect());
|
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 {
|
return {
|
||||||
rootColumnError: Math.abs(root.left + root.width / 2 - spine.left - spine.width / 2),
|
rootColumnError: Math.abs(foot.x - spine.left - spine.width / 2),
|
||||||
rootGap: Math.abs(root.bottom - spine.top),
|
rootGap: Math.abs(root.bottom - spine.top),
|
||||||
frameCells: frame.width / cell,
|
frameCells: frame.width / cell,
|
||||||
rowHeights: rows.map((row) => row.height),
|
rowHeights: rows.map((row) => row.height),
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ test('home and book are usable without client JavaScript', async ({ page }, test
|
|||||||
const spine = await page.locator('.branch-spine').first().boundingBox();
|
const spine = await page.locator('.branch-spine').first().boundingBox();
|
||||||
expect(root).not.toBeNull();
|
expect(root).not.toBeNull();
|
||||||
expect(spine).not.toBeNull();
|
expect(spine).not.toBeNull();
|
||||||
expect(Math.abs(root!.x + root!.width / 2 - spine!.x - spine!.width / 2)).toBeLessThan(1);
|
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(root!.y + root!.height - spine!.y)).toBeLessThan(1);
|
||||||
await page.screenshot({ path: testInfo.outputPath('home.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('home.png'), fullPage: true });
|
||||||
await page.getByRole('link', { name: 'Book', exact: true }).click();
|
await page.getByRole('link', { name: 'Book', exact: true }).click();
|
||||||
|
|||||||
Reference in New Issue
Block a user