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');