diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index 66283e7..f12a95f 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -44,20 +44,22 @@ const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1) tabindex={object.path === initialObject.path ? 0 : -1} disabled > {root ? : <>{object.name}{children.length > 0 && }} {root && worldspace} diff --git a/site/src/scripts/landing.ts b/site/src/scripts/landing.ts index b87f114..2408c52 100644 --- a/site/src/scripts/landing.ts +++ b/site/src/scripts/landing.ts @@ -38,6 +38,8 @@ export async function initLanding() { const motion = matchMedia('(prefers-reduced-motion: reduce)'); const get = (selector: string) => stage.querySelector(selector)!; const root = get('.root-mark'); + const slashArt = get('.slash-art'); + const restingSlashScale = Number(getComputedStyle(slashArt).getPropertyValue('--slash-rest-scale')); const trunk = get('.trunk-line'); const trunkAnchor = get('.trunk-anchor'); const trunkReveal = get('.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; diff --git a/site/src/styles/landing.css b/site/src/styles/landing.css index 7329132..bdffd11 100644 --- a/site/src/styles/landing.css +++ b/site/src/styles/landing.css @@ -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); } diff --git a/site/tests/browser/landing.spec.ts b/site/tests/browser/landing.spec.ts index a631c27..8d0569d 100644 --- a/site/tests/browser/landing.spec.ts +++ b/site/tests/browser/landing.spec.ts @@ -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('.root-vector')!.getScreenCTM()!); + const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector('.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('.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('.node-row:not(.root-slot)')].map((row) => row.getBoundingClientRect()); const nodes = [...document.querySelectorAll('.demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect()); - const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector('.root-vector')!.getScreenCTM()!); + const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector('.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');