fix: shrink the resting slash independently of the tree
Reference site / build (push) Canceled after 0s
Reference site / build (push) Canceled after 0s
This commit is contained in:
@@ -44,20 +44,22 @@ const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1)
|
||||
tabindex={object.path === initialObject.path ? 0 : -1} disabled
|
||||
>
|
||||
{root ? <svg class="root-vector" viewBox="0 0 48 64" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<mask id="tap-edge-mask" maskUnits="userSpaceOnUse" x="-8" y="4" width="64" height="56" mask-type="alpha">
|
||||
<rect x="-8" y="4" width="64" height="56" fill="white" />
|
||||
</mask>
|
||||
</defs>
|
||||
<g class="tap-mask" mask="url(#tap-edge-mask)">
|
||||
<g class="tap-stream tap-stream-a" transform="translate(5 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
|
||||
<g class="tap-stream tap-stream-b" transform="translate(22 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
|
||||
<g class="slash-art">
|
||||
<defs>
|
||||
<mask id="tap-edge-mask" maskUnits="userSpaceOnUse" x="-8" y="4" width="64" height="56" mask-type="alpha">
|
||||
<rect x="-8" y="4" width="64" height="56" fill="white" />
|
||||
</mask>
|
||||
</defs>
|
||||
<g class="tap-mask" mask="url(#tap-edge-mask)">
|
||||
<g class="tap-stream tap-stream-a" transform="translate(5 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
|
||||
<g class="tap-stream tap-stream-b" transform="translate(22 60) rotate(-66.801409)"><text x="-18" y="0">{'Tap → '.repeat(8)}</text></g>
|
||||
</g>
|
||||
<path class="root-shape" d="M31 4H41L17 60H7Z" />
|
||||
</g>
|
||||
<g class="trunk-anchor" transform="translate(12 64) scale(2.6666666667)" data-stem-width={stemWidth} data-branch-y={branchY} data-branch-thickness={branchThickness}>
|
||||
<defs><mask id="trunk-growth-mask" maskUnits="userSpaceOnUse" x={-stemWidth} y="0" width={stemWidth * 2} height="10000" mask-type="alpha"><rect class="trunk-reveal" x={-stemWidth} y="0" width={stemWidth * 2} height={trunkLength} fill="white" /></mask></defs>
|
||||
<g mask="url(#trunk-growth-mask)"><path class="trunk-line" d={trunkPath} transform={`scale(1 ${trunkLength})`} /></g>
|
||||
</g>
|
||||
<path class="root-shape" d="M31 4H41L17 60H7Z" />
|
||||
</svg> : <><span class="node-label">{object.name}</span>{children.length > 0 && <span class="node-indicator" aria-hidden="true">−</span>}</>}
|
||||
</button>
|
||||
{root && <span class="root-caption" data-reveal>worldspace</span>}
|
||||
|
||||
@@ -38,6 +38,8 @@ export async function initLanding() {
|
||||
const motion = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const get = <T extends Element = HTMLElement>(selector: string) => stage.querySelector<T>(selector)!;
|
||||
const root = get<HTMLButtonElement>('.root-mark');
|
||||
const slashArt = get<SVGGElement>('.slash-art');
|
||||
const restingSlashScale = Number(getComputedStyle(slashArt).getPropertyValue('--slash-rest-scale'));
|
||||
const trunk = get<SVGPathElement>('.trunk-line');
|
||||
const trunkAnchor = get<SVGGElement>('.trunk-anchor');
|
||||
const trunkReveal = get<SVGRectElement>('.trunk-reveal');
|
||||
@@ -103,7 +105,7 @@ export async function initLanding() {
|
||||
panels.forEach((panel) => panel.removeAttribute('data-glitching'));
|
||||
}
|
||||
|
||||
const cues = { time: 0, travel: 0, scale: 7.2, extension: 0, converge: 0, release: 0, turn: -28, trunkOpacity: 0 };
|
||||
const cues = { time: 0, travel: 0, scale: 7.2, slashScale: 1, extension: 0, converge: 0, release: 0, turn: -28, trunkOpacity: 0 };
|
||||
const timeline = createTimeline({
|
||||
autoplay: false,
|
||||
defaults: { ease: 'linear' },
|
||||
@@ -119,6 +121,7 @@ export async function initLanding() {
|
||||
.add(cues, { time: [0, flow.duration], duration: flow.duration }, 0)
|
||||
.add(cues, { travel: [0, 1], duration: 3000, ease: timing.travel }, 0)
|
||||
.add(cues, { scale: [7.2, 1], duration: 2650, ease: timing.scale }, 350)
|
||||
.add(cues, { slashScale: [1, restingSlashScale], duration: 2650, ease: timing.scale }, 350)
|
||||
.add(cues, { extension: [0, 1], duration: 1000, ease: timing.trace }, 'growth')
|
||||
.add(cues, { trunkOpacity: [0, 1], duration: 100 }, 'growth')
|
||||
.add(cues, { converge: [0, 1], duration: 560, ease: timing.travel }, flow.converge)
|
||||
@@ -258,6 +261,7 @@ export async function initLanding() {
|
||||
restoreGlitch();
|
||||
scene.style.removeProperty('transform');
|
||||
scene.style.removeProperty('will-change');
|
||||
slashArt.style.removeProperty('--slash-scale');
|
||||
rows.slice(1).forEach((row) => { row.style.removeProperty('transform'); });
|
||||
html.style.removeProperty('--landing-chrome');
|
||||
trunk.style.opacity = '1';
|
||||
@@ -287,9 +291,11 @@ export async function initLanding() {
|
||||
}
|
||||
|
||||
// Anime.js owns the clock and easing; this renderer resolves only the
|
||||
// responsive spatial geometry. Every tree element shares one transform.
|
||||
// responsive spatial geometry. Tree layout retains one shared transform;
|
||||
// the slash ink has a separate size cue anchored above the unchanged trunk.
|
||||
function render() {
|
||||
const time = cues.time;
|
||||
slashArt.style.setProperty('--slash-scale', String(cues.slashScale));
|
||||
const treeTarget = tree.getBoundingClientRect();
|
||||
const endX = treeTarget.left + parseFloat(getComputedStyle(slot).paddingLeft) + parseFloat(getComputedStyle(root).width) / 2;
|
||||
const endY = treeTarget.top + slot.offsetTop + slot.offsetHeight - root.offsetHeight / 2;
|
||||
|
||||
@@ -50,6 +50,10 @@ html[data-landing] .landing-page .site-header { opacity: 0; }
|
||||
.node-indicator { width: 1ch; color: var(--muted); }
|
||||
.root-mark { width: 3ch; height: var(--row); display: block; padding: 0; background: transparent !important; transform-origin: center; z-index: 25; transition: none; }
|
||||
.root-vector { display: block; width: 100%; height: 100%; overflow: visible; }
|
||||
/* Only the slash ink shrinks from its original intro size to 14px / 24px.
|
||||
Pivot at the fixed trunk origin; never scale the trunk or the tree layout. */
|
||||
.slash-art { --slash-rest-scale: 0.5833333333333333; transform-origin: 12px 64px; transform: scale(var(--slash-scale, var(--slash-rest-scale))); }
|
||||
html[data-landing="boot"] .slash-art { --slash-scale: 1; }
|
||||
.root-shape { fill: currentColor; }
|
||||
.root-mark[aria-selected="true"] { color: #4e752d; }
|
||||
.root-caption { margin-left: calc(2ch - var(--row) * .1875); height: var(--row); color: var(--muted); }
|
||||
|
||||
@@ -16,13 +16,14 @@ async function trunkGeometry(page: import('@playwright/test').Page) {
|
||||
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()!);
|
||||
const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGPathElement>('.root-shape')!.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,
|
||||
rowHeight: document.querySelector('.node-row:not(.root-slot)')!.getBoundingClientRect().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,
|
||||
@@ -39,10 +40,50 @@ async function armed(page: import('@playwright/test').Page) {
|
||||
async function complete(page: import('@playwright/test').Page) {
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete', { timeout: 10000 });
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
expect((await page.locator('.root-shape').boundingBox())!.height).toBeCloseTo(12.25, 4);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await expect(page.getByRole('link', { name: 'Book', exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test('only the resting slash shrinks; intro ink and tree dimensions stay unchanged', 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 slash = page.locator('.root-shape');
|
||||
const initial = (await slash.boundingBox())!;
|
||||
expect(initial.height).toBeCloseTo(151.2, 3); // Original 21px ink × 7.2 intro scale.
|
||||
expect(initial.width).toBeCloseTo(91.8, 3);
|
||||
await page.screenshot({ path: testInfo.outputPath('slash-initial-unchanged.png') });
|
||||
await page.keyboard.press('Enter');
|
||||
let previousTime = 0, previousHeight = initial.height;
|
||||
for (const time of [500, 1400, 2200, 2800, 4000]) {
|
||||
await page.clock.runFor(time - previousTime);
|
||||
const ink = await trunkGeometry(page);
|
||||
expect(ink.slashHeight).toBeLessThan(previousHeight);
|
||||
// Shared scale is still applied to the original 24px rows and 1.33px stem.
|
||||
expect(ink.width / ink.rowHeight).toBeCloseTo(1.33 / 24, 5);
|
||||
expect(Math.abs(ink.axis)).toBeLessThan(.01);
|
||||
previousHeight = ink.slashHeight;
|
||||
previousTime = time;
|
||||
}
|
||||
await complete(page);
|
||||
const final = (await slash.boundingBox())!;
|
||||
expect(final.width).toBeCloseTo(7.4375, 4);
|
||||
const dimensions = () => page.evaluate(() => ({
|
||||
rows: [...document.querySelectorAll('.node-row, .demo-node, .branch-prefix')].map((el) => el.getBoundingClientRect().toJSON()),
|
||||
trunk: document.querySelector('.trunk-line')!.getBoundingClientRect().toJSON(),
|
||||
textSize: getComputedStyle(document.querySelector('.demo-node:not(.root-mark)')!).fontSize,
|
||||
}));
|
||||
const tree = await dimensions();
|
||||
expect(tree.textSize).toBe('14px');
|
||||
expect(tree.trunk.width).toBeCloseTo(1.33, 4);
|
||||
await page.locator('.slash-art').evaluate((el) => (el as SVGGElement).style.setProperty('--slash-scale', '1'));
|
||||
expect((await slash.boundingBox())!.height).toBeCloseTo(21, 4);
|
||||
expect(await dimensions()).toEqual(tree); // Changing only slash ink cannot resize/reflow the tree.
|
||||
await page.locator('.slash-art').evaluate((el) => (el as SVGGElement).style.removeProperty('--slash-scale'));
|
||||
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('slash-final-smaller.png') });
|
||||
});
|
||||
|
||||
for (const trigger of ['click', 'wheel', 'key']) {
|
||||
test(`one ${trigger} plays through to the interactive world without more input`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
@@ -142,7 +183,7 @@ test.describe('persistent vertical trunk', () => {
|
||||
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);
|
||||
const foot = new DOMPoint(12, 60).matrixTransform(svg.querySelector<SVGPathElement>('.root-shape')!.getScreenCTM()!);
|
||||
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 };
|
||||
@@ -179,8 +220,8 @@ test('completed tree aligns the slash foot with rendered box-drawing ink', async
|
||||
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()!);
|
||||
const foot = await page.locator('.root-shape').evaluate((svg) => {
|
||||
const point = new DOMPoint(12, 60).matrixTransform((svg as SVGGraphicsElement).getScreenCTM()!);
|
||||
return { x: point.x, y: point.y };
|
||||
});
|
||||
// Sample the painted vertical stroke, below the slash and above the first
|
||||
@@ -208,7 +249,7 @@ test('completed tree aligns the slash foot with rendered box-drawing ink', async
|
||||
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 follows the unchanged tree scale while the slash shrinks independently', 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);
|
||||
@@ -221,7 +262,7 @@ test('trunk thickness and origin stay proportional to the slash throughout scali
|
||||
expect(ink.opacity).toBeGreaterThan(0);
|
||||
// At rest: tree ink is weighted 25% above the font's native 1.064px stem.
|
||||
expect(ink.stemWidth).toBeCloseTo(1.33, 5);
|
||||
expect(ink.width / ink.slashHeight).toBeCloseTo(ink.stemWidth / 21, 5);
|
||||
expect(ink.width / ink.rowHeight).toBeCloseTo(ink.stemWidth / 24, 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);
|
||||
@@ -381,7 +422,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
|
||||
const frame = document.querySelector('.landing-content')!.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 foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGSVGElement>('.root-vector')!.getScreenCTM()!);
|
||||
const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGPathElement>('.root-shape')!.getScreenCTM()!);
|
||||
return {
|
||||
rootColumnError: Math.abs(foot.x - prefix.getBoundingClientRect().left - cell / 2),
|
||||
rootGap: Math.abs(root.bottom - prefix.getBoundingClientRect().top),
|
||||
@@ -595,6 +636,7 @@ test.describe('JavaScript-free glyph tree', () => {
|
||||
await page.goto('./');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
await expect(page.locator('.no-script-note')).toBeVisible();
|
||||
expect((await page.locator('.root-shape').boundingBox())!.height).toBeCloseTo(12.25, 4);
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user