fix: shrink the resting slash independently of the tree
Reference site / build (push) Canceled after 0s

This commit is contained in:
2026-10-11 01:39:56 +09:00
parent 6897b2368a
commit ae2c236df7
4 changed files with 72 additions and 18 deletions
+11 -9
View File
@@ -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>}
+8 -2
View File
@@ -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;
+4
View File
@@ -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); }
+49 -7
View File
@@ -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');