Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/web/src/components/HomePage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,55 @@ const principleIcons = [Layers2, ShieldCheck, BadgeCheck]
</div>
</section>
</main>
<script is:inline>
(() => {
const proofs = [...document.querySelectorAll('[data-scroll-proof]')]
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
if (!proofs.length) return

const resetProofs = () => {
proofs.forEach((proof) => {
proof.style.removeProperty('--proof-opacity')
proof.style.removeProperty('--proof-rotate')
proof.style.removeProperty('--proof-lift')
proof.style.removeProperty('--proof-scale')
})
}

const syncProofs = () => {
const start = innerHeight * 0.92
const end = innerHeight * 0.18
const distance = Math.max(1, start - end)

proofs.forEach((proof) => {
const progress = Math.min(1, Math.max(0, (start - proof.getBoundingClientRect().top) / distance))
proof.style.setProperty('--proof-opacity', String(0.62 + progress * 0.38))
proof.style.setProperty('--proof-rotate', `${18 - progress * 18}deg`)
proof.style.setProperty('--proof-lift', `${(1 - progress) * 3.5}rem`)
proof.style.setProperty('--proof-scale', String(0.84 + progress * 0.16))
})
}

const syncMotion = () => {
const enabled = !motionQuery.matches
document.documentElement.toggleAttribute('data-project-proof-motion', enabled)
if (enabled) syncProofs()
else resetProofs()
}

let scheduled = false
const requestSync = () => {
if (scheduled) return
scheduled = true
window.setTimeout(() => {
scheduled = false
if (!motionQuery.matches) syncProofs()
}, 16)
}

addEventListener('scroll', requestSync, { passive: true })
addEventListener('resize', requestSync)
motionQuery.addEventListener('change', syncMotion)
syncMotion()
})()
</script>
97 changes: 93 additions & 4 deletions apps/web/src/components/home/HomeProjectProof.astro
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,103 @@ const artifact = artifacts[kind]
const planned = status === 'planned'
---

<aside id={proofId} class="home-project-proof" data-proof-count={projectProof?.length ?? 0} aria-label={`${projectName} · ${artifact.label}`}>
<aside id={proofId} class="home-project-proof" data-scroll-proof data-proof-count={projectProof?.length ?? 0} aria-label={`${projectName} · ${artifact.label}`}>
<div class="home-project-proof-heading">
<span class="home-project-proof-label">{artifact.label}</span>
<div>
<strong class="home-project-proof-project">{projectName}</strong>
<span class="home-project-proof-label">{artifact.label}</span>
</div>
<span class="home-project-proof-marker">PROOF</span>
</div>
{planned ? <p class="home-project-proof-planned">{locale === 'zh-CN' ? '规划中 · 当前不提供可安装命令' : 'Planned · no install command is available yet'}</p> : artifact.command ? <p class="home-project-proof-command"><code>{artifact.command}</code></p> : null}
{planned && projectProof?.length ? <ul class="home-project-proof-list" aria-label={locale === 'zh-CN' ? '规划证明条目' : 'Planning proof items'}>{projectProof.map(item => <li>{typeof item === 'string' ? item : item[locale]}</li>)}</ul> : <CodePanel parts={planned ? [{ text: 'status: planned\\napi: to be confirmed\\nplatforms: to be confirmed' }] : artifact.parts} locale={locale} filename={planned ? 'planning.md' : artifact.file} />}
</aside>
<style>
.home-project-proof-planned { margin: 0; color: var(--muted); font: 650 .75rem/1.5 var(--font-mono); }
.home-project-proof-list { display:grid; gap:.5rem; margin:0; padding:.8rem 1rem .9rem 2rem; color:var(--muted); font:500 .75rem/1.5 var(--font-mono); }
.home-project-proof {
display: grid;
gap: 1rem;
min-width: 0;
height: 100%;
padding: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 14px 34px var(--shadow);
}
.home-project-proof-heading {
display: flex;
gap: 1rem;
align-items: flex-start;
justify-content: space-between;
}
.home-project-proof-project {
display: block;
color: var(--text);
font: 680 1rem/1.25 var(--font-display);
}
.home-project-proof-label {
display: block;
margin-top: 0.25rem;
color: var(--accent);
font: 650 0.68rem/1.4 var(--font-mono);
}
.home-project-proof-marker {
flex-shrink: 0;
padding: 0.3rem 0.45rem;
color: var(--accent);
border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--border));
border-radius: 999px;
font: 650 0.6rem/1 var(--font-mono);
letter-spacing: 0.08em;
}
.home-project-proof-planned {
margin: 0;
color: var(--muted);
font: 650 0.75rem/1.5 var(--font-mono);
}
.home-project-proof-list {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0.8rem 1rem 0.9rem 2rem;
color: var(--muted);
font: 500 0.75rem/1.5 var(--font-mono);
}
.home-project-proof-command {
padding: 0.65rem 0.75rem;
margin: 0;
overflow: auto;
background: var(--surface-soft);
border: 1px solid var(--border);
border-radius: 8px;
}
.home-project-proof-command code {
color: var(--text);
font: 650 0.72rem/1.5 var(--font-mono);
white-space: nowrap;
}
.home-project-proof :global(.demo-code) {
min-height: 0;
margin: 0;
overflow: hidden;
background: var(--surface-soft);
border: 1px solid var(--border);
border-radius: 8px;
}
.home-project-proof :global(.demo-code pre) {
height: auto;
max-height: 15rem;
min-height: 12rem;
padding: 0.85rem 1rem 1rem;
overflow: auto;
font-size: 0.76rem;
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.home-project-proof :global(.demo-pane-heading) {
height: 2.5rem;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--border);
}
</style>
12 changes: 12 additions & 0 deletions apps/web/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,18 @@
box-shadow: none;
}

/* Proof evidence enters each project row with a restrained perspective reveal. */
.home-project-proof[data-scroll-proof] {
transform-origin: center bottom;
}
@media (prefers-reduced-motion: no-preference) {
html[data-project-proof-motion] [data-scroll-proof] {
opacity: var(--proof-opacity, 0.62);
transform: perspective(1400px) rotateX(var(--proof-rotate, 18deg)) translateY(var(--proof-lift, 3.5rem))
scale(var(--proof-scale, 0.84));
}
}

.section-intro {
max-width: 720px;
margin-bottom: 2.25rem;
Expand Down
51 changes: 50 additions & 1 deletion apps/web/tests/e2e/site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ async function expectHomeVisuals(page: import('@playwright/test').Page) {
await expect(page.locator('#projects [data-project-visual]')).toHaveCount(0)
await expect(page.locator('#projects [data-project-row]')).toHaveCount(4)
await expect(page.locator('#ecosystem-taro [data-project-row]')).toHaveCount(1)
await expect(page.locator('[data-scroll-proof]')).toHaveCount(4)
await expect(page.locator('.home-project-rail-group')).toHaveCount(5)
}

Expand Down Expand Up @@ -242,7 +243,7 @@ test('hero planets stay between the wordmark and the first-screen edges', async
test('reduced motion keeps content visible and product interactions stationary', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/')
const movingOrHidden = () => page.locator('[data-reveal], [data-hero-enter], [data-project-visual] img').evaluateAll(elements => elements.filter((element) => {
const movingOrHidden = () => page.locator('[data-reveal], [data-hero-enter], [data-project-visual] img, [data-scroll-proof]').evaluateAll(elements => elements.filter((element) => {
const style = getComputedStyle(element)
return style.opacity !== '1' || style.transform !== 'none' || style.animationName !== 'none' || style.transitionDuration !== '0s'
}).map(element => element.tagName))
Expand All @@ -258,6 +259,54 @@ test('reduced motion keeps content visible and product interactions stationary',
expect(await page.evaluate(() => document.getAnimations().length)).toBe(0)
})

test('disables proof motion when reduced motion changes at runtime', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'no-preference' })
await page.goto('/')
await expect(page.locator('html')).toHaveAttribute('data-project-proof-motion', '')
await page.emulateMedia({ reducedMotion: 'reduce' })
await expect.poll(() => page.locator('html').getAttribute('data-project-proof-motion')).toBeNull()
await expect.poll(() => page.locator('[data-scroll-proof]').first().evaluate(element => ({
opacity: getComputedStyle(element).opacity,
transform: getComputedStyle(element).transform,
}))).toEqual({ opacity: '1', transform: 'none' })
})

test('scrolls project proof cards into place', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto('/')
const proof = page.locator('[data-scroll-proof]').first()
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }))
await page.waitForTimeout(50)
const initial = await proof.evaluate(element => ({
opacity: getComputedStyle(element).opacity,
transform: getComputedStyle(element).transform,
}))
await page.evaluate(() => window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }))
await page.waitForTimeout(100)
const settled = await proof.evaluate(element => ({
opacity: getComputedStyle(element).opacity,
transform: getComputedStyle(element).transform,
}))
expect(Number(settled.opacity)).toBeGreaterThan(Number(initial.opacity))
expect(settled.transform).not.toBe(initial.transform)
})

test('keeps project proof cards within responsive viewports', async ({ page }) => {
for (const viewport of [1440, 700, 390]) {
await page.setViewportSize({ width: viewport, height: 900 })
await page.goto('/')
const layout = await page.locator('[data-scroll-proof]').evaluateAll(elements => ({
cards: elements.length,
boxes: elements.map((element) => {
const box = element.getBoundingClientRect()
return { left: box.left, right: box.right, width: box.width }
}),
}))
expect(layout.cards, `${viewport}px proof card count`).toBe(4)
expect(layout.boxes.every(box => box.width > 0 && box.left >= -1 && box.right <= viewport + 1), `${viewport}px proof card bounds`).toBe(true)
}
})

test('reveals content after the timeout when the observer never reports visibility', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'no-preference' })
await page.clock.install()
Expand Down
Loading