Files
Z User e45b68bb8b feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
2026-10-27 22:50:50 +00:00

1675 lines
60 KiB
HTML
Executable File

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>AERIA · 留白时刻的环境智能</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500&family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400&family=Noto+Serif+SC:wght@300;400&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0e0a; /* 深林夜色 */
--bg-deep: #050805;
--ink: #ece9df; /* 米白墨色 */
--ink-mute: #8a8c80;
--line: rgba(236, 233, 223, 0.18);
--line-soft: rgba(236, 233, 223, 0.08);
--accent: #f5e7c4; /* 暖金光 */
--accent-soft: #c9b88a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg);
color: var(--ink);
font-family: 'Noto Sans SC', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-weight: 300;
height: 100%;
overflow: hidden;
cursor: none;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body { letter-spacing: 0; }
a { color: inherit; text-decoration: none; }
.serif {
font-family: 'Cormorant Garamond', 'Noto Serif SC', serif;
font-style: italic; font-weight: 300;
}
.serif-cn { font-family: 'Noto Serif SC', serif; font-weight: 300; }
/* ---------- canvas ---------- */
#scene {
position: fixed; inset: 0;
width: 100%; height: 100%;
z-index: 1;
transition: filter 0.9s ease, opacity 0.9s ease;
}
body.page-open #scene { filter: blur(14px) brightness(0.32) saturate(0.7); }
/* ---------- vignette / atmosphere ---------- */
.vignette {
position: fixed; inset: 0; z-index: 2; pointer-events: none;
background:
radial-gradient(ellipse at 50% 35%, transparent 30%, rgba(0,0,0,0.55) 100%),
linear-gradient(180deg, rgba(255,238,200,0.04) 0%, transparent 30%, rgba(0,0,0,0.18) 100%);
mix-blend-mode: multiply;
}
.grain {
position: fixed; inset: 0; z-index: 3; pointer-events: none;
opacity: 0.08; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.55 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
/* ---------- header ---------- */
header {
position: fixed; top: 0; left: 0; right: 0;
z-index: 60;
display: flex; align-items: center; justify-content: space-between;
padding: 24px 36px;
}
.logo {
font-family: 'Cormorant Garamond', serif;
font-style: italic; font-size: 24px; letter-spacing: 0.02em;
color: var(--ink);
}
.logo sup { font-size: 10px; vertical-align: super; opacity: 0.5; margin-left: 2px; }
nav.main {
display: flex; gap: 32px;
font-size: 12px;
color: var(--ink);
}
nav.main a {
position: relative; padding: 4px 0;
overflow: hidden; display: inline-block;
height: 24px; line-height: 24px;
}
nav.main a span {
display: block;
height: 24px; line-height: 24px;
transition: transform .55s cubic-bezier(.7,.05,.3,1);
letter-spacing: 0.06em;
}
nav.main a span.alt {
position: absolute; top: 100%; left: 0;
font-family: 'Cormorant Garamond', 'Noto Serif SC', serif;
font-style: italic; font-size: 16px;
letter-spacing: 0.04em;
color: var(--accent);
}
nav.main a:hover span.regular { transform: translateY(-100%); }
nav.main a:hover span.alt { transform: translateY(-100%); }
/* ---------- cursor ---------- */
.cursor {
position: fixed; top: 0; left: 0; z-index: 9999;
width: 12px; height: 12px; border-radius: 50%;
background: var(--accent);
pointer-events: none;
transform: translate(-50%, -50%);
mix-blend-mode: difference;
transition: width .25s, height .25s, background .25s, opacity .25s;
}
.cursor.hover {
width: 56px; height: 56px;
background: transparent;
border: 1px solid var(--accent);
}
.cursor.click { transform: translate(-50%, -50%) scale(0.6); }
/* ---------- loader ---------- */
.loader {
position: fixed; inset: 0; z-index: 200;
background: var(--bg);
display: flex; align-items: center; justify-content: center;
flex-direction: column;
transition: opacity 1.2s ease, visibility 1.2s ease;
}
.loader.gone { opacity: 0; visibility: hidden; pointer-events: none; }
.loader__title {
font-family: 'Cormorant Garamond', serif;
font-style: italic; font-size: clamp(56px, 10vw, 132px);
letter-spacing: -0.01em; line-height: 1;
color: var(--accent);
overflow: hidden;
display: flex;
}
.loader__title span {
display: inline-block;
transform: translateY(110%);
animation: rise 1.1s cubic-bezier(.2,.8,.2,1) forwards;
}
.loader__title span:nth-child(1){ animation-delay: 0.10s; }
.loader__title span:nth-child(2){ animation-delay: 0.18s; }
.loader__title span:nth-child(3){ animation-delay: 0.26s; }
.loader__title span:nth-child(4){ animation-delay: 0.34s; }
.loader__title span:nth-child(5){ animation-delay: 0.42s; }
@keyframes rise { to { transform: translateY(0); } }
.loader__sub {
margin-top: 22px; font-size: 11px; letter-spacing: 0.4em;
color: var(--ink-mute); opacity: 0; animation: fade 1s ease 0.9s forwards;
}
.loader__enter {
margin-top: 64px;
background: transparent; border: 1px solid var(--accent);
color: var(--accent); padding: 16px 42px;
font-family: 'Noto Sans SC', sans-serif; font-size: 12px; letter-spacing: 0.4em;
cursor: none;
opacity: 0; animation: fade 1s ease 1.4s forwards;
transition: background 0.4s, color 0.4s;
}
.loader__enter:hover { background: var(--accent); color: var(--bg); }
@keyframes fade { to { opacity: 1; } }
/* ---------- station overlay ---------- */
.stations {
position: fixed; inset: 0; z-index: 20; pointer-events: none;
}
.station {
position: absolute; left: 0; right: 0;
padding: 0 9vw;
opacity: 0; visibility: hidden;
transition: opacity 1.1s ease, transform 1.4s cubic-bezier(.2,.7,.3,1);
transform: translateY(20px);
will-change: opacity, transform;
}
.station.active { opacity: 1; visibility: visible; transform: translateY(0); }
.station--center {
top: 50%; transform: translateY(calc(-50% + 30px));
text-align: center;
}
.station--center.active { transform: translateY(-50%); }
.station--bottom { bottom: 12vh; }
.station--bottom.active { transform: translateY(0); }
.station h1 {
font-family: 'Noto Serif SC', serif;
font-weight: 300;
font-size: clamp(38px, 5.4vw, 84px);
line-height: 1.18; letter-spacing: 0.02em;
color: var(--ink);
}
.station h1 em {
font-style: normal; font-weight: 400;
color: var(--accent);
}
.station h2 {
font-family: 'Noto Serif SC', serif;
font-weight: 300;
font-size: clamp(28px, 3.8vw, 56px);
line-height: 1.25;
}
.station h2 em {
font-style: normal; font-weight: 400;
color: var(--accent);
}
.eyebrow {
font-size: 11px; letter-spacing: 0.3em;
color: var(--ink-mute); margin-bottom: 22px;
display: inline-flex; align-items: center; gap: 8px;
}
.eyebrow::before {
content: ''; width: 22px; height: 1px; background: var(--accent);
}
.lede {
margin-top: 28px;
max-width: 540px;
font-size: 15px; line-height: 1.85;
color: rgba(236,233,223,0.74);
}
.station--center .lede { margin-left: auto; margin-right: auto; }
/* feature grid station */
.grid-row {
display: flex; gap: 72px; align-items: flex-end;
max-width: 1180px;
}
.grid-row > div { flex: 1; }
.grid-num {
font-family: 'Cormorant Garamond', serif; font-style: italic;
font-size: 16px; color: var(--accent); margin-bottom: 14px;
}
.grid-title {
font-family: 'Noto Serif SC', serif;
font-size: 30px; line-height: 1.15; margin-bottom: 12px;
}
.grid-text {
font-size: 14px; line-height: 1.85; color: rgba(236,233,223,0.72);
}
.grid-meta-label {
font-size: 10px; letter-spacing: 0.3em;
color: var(--ink-mute); margin-bottom: 10px;
}
/* CTA */
.cta {
display: inline-flex; align-items: center; gap: 14px;
margin-top: 42px;
padding: 16px 32px;
border: 1px solid rgba(245,231,196,0.45);
border-radius: 100px;
font-size: 12px; letter-spacing: 0.3em;
color: var(--accent);
pointer-events: auto;
transition: background 0.4s, color 0.4s, border-color 0.4s;
cursor: none;
}
.cta:hover { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.cta svg { width: 14px; height: 14px; }
/* hint at bottom */
.hint {
position: fixed;
bottom: 26px; left: 36px;
z-index: 50;
font-size: 10px; letter-spacing: 0.32em;
color: var(--ink-mute);
display: flex; align-items: center; gap: 14px;
transition: opacity .6s;
}
.hint.fade { opacity: 0.25; }
.hint__dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--accent);
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scale(0.9);}
50% { opacity: 1; transform: scale(1.2);}
}
/* progress bar */
.progress {
position: fixed;
bottom: 26px; right: 36px;
z-index: 50;
display: flex; align-items: center; gap: 14px;
font-size: 10px; letter-spacing: 0.32em;
color: var(--ink-mute);
}
.progress__bar {
width: 140px; height: 1px;
background: rgba(236,233,223,0.22);
position: relative;
}
.progress__fill {
position: absolute; left: 0; top: 0;
height: 100%; width: 0%;
background: var(--accent);
transition: width 0.18s ease;
}
.progress__num { width: 32px; text-align: right; font-feature-settings: 'tnum'; }
/* chapter index left */
.chapters {
position: fixed;
top: 50%; left: 36px;
transform: translateY(-50%);
z-index: 50;
display: flex; flex-direction: column; gap: 18px;
}
.chapters__item {
display: flex; align-items: center; gap: 14px;
cursor: none; pointer-events: auto;
color: var(--ink-mute); font-size: 11px; letter-spacing: 0.18em;
opacity: 0.55;
transition: opacity .4s, color .4s;
}
.chapters__item:hover { opacity: 1; }
.chapters__item.active { opacity: 1; color: var(--accent); }
.chapters__item .dash {
width: 24px; height: 1px; background: currentColor;
transition: width .4s;
}
.chapters__item.active .dash { width: 50px; }
/* ---------- sub pages ---------- */
.page {
position: fixed; inset: 0;
z-index: 100;
background: rgba(10,14,10,0.90);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
overflow-y: auto; overflow-x: hidden;
opacity: 0; visibility: hidden; pointer-events: none;
transition: opacity .8s ease, visibility .8s ease;
}
.page.open { opacity: 1; visibility: visible; pointer-events: auto; }
.page__inner {
max-width: 1280px; margin: 0 auto;
padding: 120px 8vw 120px;
}
.page__nav {
position: sticky; top: 0;
display: flex; align-items: center; justify-content: space-between;
padding: 22px 0;
margin: -120px -8vw 80px;
padding-left: 8vw; padding-right: 8vw;
background: linear-gradient(180deg, rgba(10,14,10,0.96), rgba(10,14,10,0));
backdrop-filter: blur(8px);
z-index: 5;
}
.page__back {
font-size: 11px; letter-spacing: 0.3em;
color: var(--ink-mute); cursor: none;
display: flex; align-items: center; gap: 12px;
transition: color .3s;
}
.page__back:hover { color: var(--accent); }
.page__crumb {
font-family: 'Cormorant Garamond', 'Noto Serif SC', serif;
font-style: italic;
font-size: 14px; color: var(--ink-mute);
}
.page__hero {
border-top: 1px solid var(--line);
padding-top: 36px;
margin-bottom: 80px;
}
.page__eyebrow {
font-size: 11px; letter-spacing: 0.3em;
color: var(--accent); margin-bottom: 24px;
}
.page__title {
font-family: 'Noto Serif SC', serif; font-weight: 300;
font-size: clamp(48px, 7vw, 108px);
line-height: 1.1; letter-spacing: 0.02em;
}
.page__title em {
font-style: normal; font-weight: 400;
color: var(--accent);
}
.page__lede {
margin-top: 44px;
max-width: 660px;
font-size: 18px; line-height: 1.7;
color: rgba(236,233,223,0.78);
}
/* features page */
.feat-list { display: grid; gap: 0; }
.feat-row {
display: grid;
grid-template-columns: 80px 1.1fr 2fr 1.4fr;
gap: 36px;
padding: 56px 0;
border-top: 1px solid var(--line);
align-items: start;
}
.feat-row:last-child { border-bottom: 1px solid var(--line); }
.feat-row__num {
font-family: 'Cormorant Garamond', serif; font-style: italic;
font-size: 18px; color: var(--accent);
}
.feat-row__name {
font-family: 'Noto Serif SC', serif;
font-size: 38px; line-height: 1.05;
font-weight: 300;
}
.feat-row__name em {
font-family: 'Cormorant Garamond', serif;
font-style: italic; color: var(--accent);
margin-right: 8px;
}
.feat-row__desc {
font-size: 14px; line-height: 1.85; color: rgba(236,233,223,0.78);
}
.feat-row__meta {
font-size: 11px; line-height: 1.85; color: var(--ink-mute);
letter-spacing: 0.1em;
}
.feat-row__meta b {
color: var(--ink); font-weight: 400;
display: block; margin-bottom: 4px;
letter-spacing: 0.18em; font-size: 10px;
}
/* pricing */
.price-grid {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 24px;
margin-top: 60px;
}
.price-card {
border: 1px solid var(--line);
padding: 40px 32px;
display: flex; flex-direction: column; min-height: 500px;
background: rgba(245, 231, 196, 0.03);
transition: border-color .4s, background .4s, transform .4s;
}
.price-card:hover {
border-color: var(--accent);
transform: translateY(-4px);
}
.price-card.featured {
border-color: var(--accent);
background: rgba(245,231,196,0.06);
}
.price-card__name {
font-family: 'Noto Serif SC', serif;
font-size: 32px; margin-bottom: 6px; font-weight: 300;
}
.price-card__name em {
font-family: 'Cormorant Garamond', serif; font-style: italic;
color: var(--accent); margin-right: 8px;
}
.price-card__tag {
font-size: 11px; letter-spacing: 0.18em;
color: var(--ink-mute); margin-bottom: 30px;
}
.price-card__amt {
font-family: 'Cormorant Garamond', serif;
font-size: 64px; line-height: 1; margin-bottom: 6px;
color: var(--ink);
}
.price-card__amt sup { font-size: 22px; vertical-align: top; opacity: 0.6; }
.price-card__per { font-size: 12px; color: var(--ink-mute); margin-bottom: 28px; }
.price-card__feat {
list-style: none; flex-grow: 1;
border-top: 1px solid var(--line);
padding-top: 22px;
}
.price-card__feat li {
padding: 10px 0; font-size: 13px;
color: rgba(236,233,223,0.82);
border-bottom: 1px solid var(--line-soft);
line-height: 1.5;
}
.price-card__cta {
margin-top: 28px; padding: 14px 0;
text-align: center;
font-size: 11px; letter-spacing: 0.3em;
border: 1px solid var(--line); cursor: none;
transition: background .3s, color .3s, border-color .3s;
}
.price-card__cta:hover { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.price-card.featured .price-card__cta { border-color: var(--accent); color: var(--accent); }
.price-card.featured .price-card__cta:hover { background: var(--accent); color: var(--bg); }
/* story */
.story-grid {
display: grid; grid-template-columns: 1fr 1fr;
gap: 80px;
margin-bottom: 80px;
align-items: start;
}
.story-img {
width: 100%; aspect-ratio: 4/5; object-fit: cover;
filter: grayscale(0.15) contrast(1.02) sepia(0.08);
}
.story-text p {
font-size: 15px; line-height: 1.95;
color: rgba(236,233,223,0.85);
margin-bottom: 24px;
}
.story-text p.first::first-letter {
font-family: 'Cormorant Garamond', serif; font-style: italic;
font-size: 88px; line-height: 0.85; float: left;
margin: 6px 18px 0 0; color: var(--accent);
}
.story-pull {
font-family: 'Noto Serif SC', serif;
font-size: 38px; line-height: 1.4; font-weight: 300;
margin: 100px auto;
max-width: 820px; text-align: center;
color: var(--ink);
position: relative;
}
.story-pull em {
font-style: normal; font-weight: 400;
color: var(--accent);
}
.story-pull::before, .story-pull::after {
content: '"';
font-family: 'Cormorant Garamond', serif;
font-style: italic;
font-size: 80px; line-height: 0;
color: var(--accent); opacity: 0.4;
position: relative; vertical-align: -0.4em;
}
.story-pull::after { content: '"'; }
.credits {
border-top: 1px solid var(--line); padding-top: 44px;
display: grid; grid-template-columns: repeat(4, 1fr); gap: 32px;
font-size: 13px;
}
.credits dt {
font-size: 10px; letter-spacing: 0.28em;
color: var(--ink-mute); margin-bottom: 10px;
}
.credits dd { color: var(--ink); line-height: 1.7; }
/* contact */
.contact-grid {
display: grid; grid-template-columns: 1.1fr 1fr;
gap: 80px;
}
.contact-form { display: flex; flex-direction: column; gap: 22px; }
.contact-form input, .contact-form textarea {
background: transparent;
border: none; border-bottom: 1px solid var(--line);
color: var(--ink); padding: 14px 0;
font-family: inherit; font-size: 14px;
outline: none; cursor: none;
transition: border-color .3s;
}
.contact-form input::placeholder, .contact-form textarea::placeholder {
color: var(--ink-mute);
}
.contact-form input:focus, .contact-form textarea:focus { border-color: var(--accent); }
.contact-form textarea { resize: none; min-height: 140px; }
.contact-form button {
align-self: flex-start;
margin-top: 14px; padding: 16px 38px;
background: var(--ink); border: none; color: var(--bg);
font-family: inherit; font-size: 11px; letter-spacing: 0.3em;
cursor: none;
transition: background .3s;
}
.contact-form button:hover { background: var(--accent); }
.contact-info { font-size: 14px; line-height: 1.9; color: rgba(236,233,223,0.78); }
.contact-info h4 {
font-family: 'Noto Serif SC', serif;
font-size: 22px; margin-bottom: 16px; color: var(--ink);
font-weight: 300;
}
.contact-info h4 em {
font-family: 'Cormorant Garamond', serif; font-style: italic;
color: var(--accent);
}
.contact-info p { margin-bottom: 32px; }
.contact-info a {
border-bottom: 1px solid var(--line);
padding-bottom: 2px;
transition: border-color .3s, color .3s;
}
.contact-info a:hover { color: var(--accent); border-color: var(--accent); }
/* page footer */
.page__foot {
margin-top: 120px;
padding-top: 40px;
border-top: 1px solid var(--line);
display: flex; justify-content: space-between; align-items: center;
font-size: 11px; color: var(--ink-mute);
letter-spacing: 0.18em;
}
/* responsive */
@media (max-width: 900px) {
nav.main { display: none; }
.chapters { display: none; }
.progress { right: 18px; bottom: 18px; }
.progress__bar { width: 90px; }
.hint { left: 18px; bottom: 18px; }
.grid-row { flex-direction: column; gap: 32px; align-items: stretch; }
.price-grid { grid-template-columns: 1fr; }
.story-grid { grid-template-columns: 1fr; gap: 32px; }
.contact-grid { grid-template-columns: 1fr; gap: 32px; }
.feat-row { grid-template-columns: 40px 1fr; }
.feat-row__desc, .feat-row__meta { grid-column: 2; }
.credits { grid-template-columns: 1fr 1fr; }
.page__inner { padding: 100px 6vw 60px; }
.station { padding: 0 6vw; }
}
</style>
</head>
<body>
<!-- ============== LOADER ============== -->
<div class="loader" id="loader">
<div class="loader__title">
<span>A</span><span>E</span><span>R</span><span>I</span><span>A</span>
</div>
<div class="loader__sub">环境智能 · 始于二〇二六</div>
<button class="loader__enter" id="enter" data-hover>进入林间</button>
</div>
<!-- ============== HEADER ============== -->
<header>
<a href="#" class="logo" data-hover><em>Aeria</em><sup>®</sup></a>
<nav class="main">
<a href="#" data-route="home" data-hover><span class="regular">首页</span><span class="alt">Index</span></a>
<a href="#features" data-route="features" data-hover><span class="regular">功能</span><span class="alt">Features</span></a>
<a href="#story" data-route="story" data-hover><span class="regular">故事</span><span class="alt">Story</span></a>
<a href="#pricing" data-route="pricing" data-hover><span class="regular">价格</span><span class="alt">Pricing</span></a>
<a href="#contact" data-route="contact" data-hover><span class="regular">联系</span><span class="alt">Contact</span></a>
</nav>
</header>
<!-- ============== CANVAS + EFFECTS ============== -->
<canvas id="scene"></canvas>
<div class="vignette"></div>
<div class="grain"></div>
<!-- ============== STATIONS ============== -->
<div class="stations" id="stations">
<!-- 0: hero -->
<section class="station station--center" data-from="0.00" data-to="0.16">
<div class="eyebrow">A E R I A · 第〇〇一号</div>
<h1>一种住在<em>留白</em>里的<br/>环境<em>智能</em></h1>
<p class="lede">一个住在你房间里的小型本地模型。它倾听房间,不倾听世界;以你自己半语的声音回写。它为语言尚未抵达的瞬间而造。</p>
</section>
<!-- 1: manifesto -->
<section class="station station--bottom" data-from="0.16" data-to="0.30">
<div class="eyebrow">宣言</div>
<h2>我们为<em>语言尚未到来</em>的<br/>那一刻而造。</h2>
<p class="lede">睡前的半个念头。淋浴时未完成的一句话。星期二早晨,世界还没有变成句子之前。AERIA 是为这些时刻而生——不是为之后那场会议。</p>
</section>
<!-- 2: feature 1 - Listen -->
<section class="station station--bottom" data-from="0.30" data-to="0.46">
<div class="grid-row">
<div>
<div class="grid-num">— 其一</div>
<h2 style="font-size: clamp(36px,4.6vw,68px); line-height: 1.05;">倾听 <em style="font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--accent);">Listen</em></h2>
</div>
<div>
<p class="grid-text">永远在线,永远本地。房间成为思考的麦克风。AERIA 与你停留在此刻——除非你主动允许,否则它不会上云。</p>
</div>
<div>
<div class="grid-meta-label">运行模式</div>
<p class="grid-text" style="font-size: 12px;">本地推理 · Whisper 级语音识别<br/>声纹识别 · 零留存 · 320MB 占用</p>
</div>
</div>
</section>
<!-- 3: feature 2 - Reflect -->
<section class="station station--bottom" data-from="0.46" data-to="0.62">
<div class="grid-row">
<div>
<div class="grid-num">— 其二</div>
<h2 style="font-size: clamp(36px,4.6vw,68px); line-height: 1.05;">复述 <em style="font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--accent);">Reflect</em></h2>
</div>
<div>
<p class="grid-text">每个夜晚,AERIA 用更安静的声音读回你的一天。一封短小的口信——由一个在你心不在焉时仍在凝神的存在写下。</p>
</div>
<div>
<div class="grid-meta-label">节奏</div>
<p class="grid-text" style="font-size: 12px;">每日 90 秒口信<br/>可选每周文字汇编</p>
</div>
</div>
</section>
<!-- 4: feature 3 - Compose -->
<section class="station station--bottom" data-from="0.62" data-to="0.80">
<div class="grid-row">
<div>
<div class="grid-num">— 其三</div>
<h2 style="font-size: clamp(36px,4.6vw,68px); line-height: 1.05;">续写 <em style="font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--accent);">Compose</em></h2>
</div>
<div>
<p class="grid-text">半句话变成完整的事,呢喃变成草稿。一个能从你停顿的地方继续的笔记本——以你刚才那个语气的声音,把它写完。</p>
</div>
<div>
<div class="grid-meta-label">输出</div>
<p class="grid-text" style="font-size: 12px;">Markdown · 语音备忘 · 信件<br/>本地优先导出 · 无埋点</p>
</div>
</div>
</section>
<!-- 5: closing -->
<section class="station station--center" data-from="0.80" data-to="1.0">
<div class="eyebrow">走入更深</div>
<h1>让你最<em>安静</em>的时刻<br/>终于<em>被听见</em>。</h1>
<p class="lede">AERIA 现处于私密内测。我们每周邀请少量用户。请慢慢走进来。</p>
<a class="cta" href="#contact" data-hover>
申请内测
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
</a>
</section>
</div>
<!-- ============== UI: chapters / hint / progress ============== -->
<div class="chapters" id="chapters">
<div class="chapters__item active" data-go="0.0"><span class="dash"></span><span>序章</span></div>
<div class="chapters__item" data-go="0.20"><span class="dash"></span><span>宣言</span></div>
<div class="chapters__item" data-go="0.38"><span class="dash"></span><span>倾听</span></div>
<div class="chapters__item" data-go="0.54"><span class="dash"></span><span>复述</span></div>
<div class="chapters__item" data-go="0.71"><span class="dash"></span><span>续写</span></div>
<div class="chapters__item" data-go="0.92"><span class="dash"></span><span>步入</span></div>
</div>
<div class="hint" id="hint">
<span class="hint__dot"></span>
滚动 · 走入林中
</div>
<div class="progress">
<span class="progress__num" id="prog-num">000</span>
<div class="progress__bar"><div class="progress__fill" id="prog-fill"></div></div>
<span>步程</span>
</div>
<!-- ============== CURSOR ============== -->
<div class="cursor" id="cursor"></div>
<!-- ============== SUB PAGES ============== -->
<!-- FEATURES -->
<div class="page" id="page-features">
<div class="page__inner">
<div class="page__nav">
<a class="page__back" href="#" data-back data-hover>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回林间
</a>
<span class="page__crumb">Aeria · Features</span>
</div>
<div class="page__hero">
<div class="page__eyebrow">— 它能做什么</div>
<h1 class="page__title">一个<em>停留在</em><br/>房间里的模型</h1>
<p class="page__lede">AERIA 是一组为"陪伴"而非"搜索"设计的小工具。每一项能力都需主动开启,没有任何一项会喧哗。</p>
</div>
<div class="feat-list">
<div class="feat-row">
<div class="feat-row__num">I.</div>
<div class="feat-row__name"><em>Listen</em>倾听</div>
<div class="feat-row__desc">一层常驻的、本机运行的转写层。Whisper 级模型完整跑在你的设备上,仅在你标记"值得保留"的时候才记录片段。声纹只识别你自己。默认零留存——未被请求的片段会自然消散。</div>
<div class="feat-row__meta"><b>模式</b>本地 · 4-bit 量化<br/>320 MB 占用<br/>iOS · macOS · Linux</div>
</div>
<div class="feat-row">
<div class="feat-row__num">II.</div>
<div class="feat-row__name"><em>Reflect</em>复述</div>
<div class="feat-row__desc">每个夜晚被书写的一封短音频信。AERIA 将白天里被你标记的片段编织成一段 90 秒的叙事——以更慢、更安静版本的你自己的声音回放。这些信件仅存在你的设备上。</div>
<div class="feat-row__meta"><b>节奏</b>每日 90 秒口信<br/>可选每周文字汇编<br/>授权后克隆声纹</div>
</div>
<div class="feat-row">
<div class="feat-row__num">III.</div>
<div class="feat-row__name"><em>Compose</em>续写</div>
<div class="feat-row__desc">从你停下的地方开始的一面草稿纸。说半句话,AERIA 沿着你刚才的语气继续把它写完。适合写信、悼词、明信片、慢通信——任何想要"听起来像你、只是把它写完"的事情。</div>
<div class="feat-row__meta"><b>输出</b>Markdown · txt · 语音备忘<br/>本地优先导出<br/>无任何分析埋点</div>
</div>
<div class="feat-row">
<div class="feat-row__num">IV.</div>
<div class="feat-row__name"><em>Drift</em>留白</div>
<div class="feat-row__desc">为缓慢时段而设的长形态——散步、泡澡、火车上。AERIA 不再回应,只陪伴。它倾听、归档,只在你回来的时候才再开口。Drift 模式内没有任何通知。</div>
<div class="feat-row__meta"><b>会话</b>15 分钟至 8 小时<br/>仅后台运行<br/>一键开启</div>
</div>
<div class="feat-row">
<div class="feat-row__num">V.</div>
<div class="feat-row__name"><em>Grove</em>林间</div>
<div class="feat-row__desc">为"两个人"设计的共享模式——伴侣、合作者、共同抚养孩子的人。两台 AERIA 各自在自己的房间里倾听,并在每一天结束时交换简短的、被双方认可的口信。原始声音从不被传输,未经双向确认的内容不会共享。</div>
<div class="feat-row__meta"><b>隐私</b>端到端摘要<br/>对称授权<br/>每条可单独撤回</div>
</div>
</div>
<div class="page__foot">
<span>© 2026 Aeria Studio · 卑尔根 · 东京 · 上海</span>
<span>文档 · v0.7 · 2026 年 5 月</span>
</div>
</div>
</div>
<!-- STORY -->
<div class="page" id="page-story">
<div class="page__inner">
<div class="page__nav">
<a class="page__back" href="#" data-back data-hover>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回林间
</a>
<span class="page__crumb">Aeria · Story</span>
</div>
<div class="page__hero">
<div class="page__eyebrow">— 缘起</div>
<h1 class="page__title">一段穿过<em>某人</em><br/>沉默的<em>散步</em></h1>
</div>
<div class="story-grid">
<img class="story-img" alt="挪威清晨的桦树林" src="../assets/forest-03-birch.jpg" />
<div class="story-text">
<p class="first">AERIA 始于 2025 年十一月第三周,挪威 Voss 郊外一条被雾打湿的林间小径。天刚要亮。树是桦树,地面是潮的。创始人——一个过去八年都在做"会打断你"的语音助手的人——第一次在很久以来停下脚步,去听一种没有内容的声音。</p>
<p>那一刻浮起的念头很小:<em>为什么我们做的每一个 AI 都在喊?</em></p>
<p>过去数年里,他在斯德哥尔摩、东京、旧金山参与过的产品有一个共性——它们全部为"被询问"那个瞬间而优化。它们大声唤醒;它们按"自信度"对答案排序;它们假定使用者已经处于语言之中。</p>
<p>但大多数思考并不发生在语言里。大多数思考发生在语言尚未抵达的长长的安静里——洗澡时、散步时、星期二清晨的半梦半醒之间。好的念头不会自己宣布到来。它们只是落下来。</p>
</div>
</div>
<p class="story-pull">我们想做一个<em>能在沉默里坐着</em>的模型——<br/>而不是一个一直忙着填补它的。</p>
<div class="story-grid">
<div class="story-text">
<p>第一版原型是 2 月在卑尔根用一个长周末搭的。一只麦克风、一个本地小模型、一条规则——模型一天只能往一个 txt 文件里回写一次,没有界面,没有实时回应,没有任何提醒。</p>
<p>我们把它发给七个人。三个人在一周内退回了笔记本。两个人在一个月后停止使用。剩下两个人到今天连续用了九个月。两人都说了同一句话:他们多年前就停止写日记了,从这之后又开始写了。</p>
<p>那已经够我们继续做下去了。</p>
</div>
<img class="story-img" alt="安静的书桌" src="../assets/forest-04-desk.jpg" />
</div>
<dl class="credits">
<div><dt>创立</dt><dd>卑尔根 · 2025 年 3 月</dd></div>
<div><dt>团队</dt><dd>九人 · 零 KPI</dd></div>
<div><dt>支持</dt><dd>Slowmade Ventures<br/>Hyll · 私人投资</dd></div>
<div><dt>状态</dt><dd>私密内测<br/>2026 年 5 月</dd></div>
</dl>
<div class="page__foot">
<span>© 2026 Aeria Studio</span>
<span>故事 · v0.4</span>
</div>
</div>
</div>
<!-- PRICING -->
<div class="page" id="page-pricing">
<div class="page__inner">
<div class="page__nav">
<a class="page__back" href="#" data-back data-hover>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回林间
</a>
<span class="page__crumb">Aeria · Pricing</span>
</div>
<div class="page__hero">
<div class="page__eyebrow">— 价格</div>
<h1 class="page__title">通往同一片<em>林子</em><br/>的<em>三扇门</em></h1>
<p class="page__lede">三种方案运行的是同一个本地模型。区别在于节奏、声音,以及你愿意把这片林子分享给多少人。</p>
</div>
<div class="price-grid">
<div class="price-card">
<div class="price-card__name"><em>Resident</em>居者</div>
<div class="price-card__tag">为独自倾听的人</div>
<div class="price-card__amt"><sup>¥</sup>0</div>
<div class="price-card__per">永久免费 · 单台设备</div>
<ul class="price-card__feat">
<li>· 本地 Listen 模式</li>
<li>· 每日 Reflect 口信</li>
<li>· Markdown 导出</li>
<li>· 单设备 · 单声纹</li>
<li>· 无云端 · 无遥测</li>
</ul>
<div class="price-card__cta" data-hover>静静开始</div>
</div>
<div class="price-card featured">
<div class="price-card__name"><em>Forest</em>森者</div>
<div class="price-card__tag">最多人选择</div>
<div class="price-card__amt"><sup>¥</sup>108</div>
<div class="price-card__per">每月 · 至多三台设备</div>
<ul class="price-card__feat">
<li>· 包含居者全部</li>
<li>· 无限 Compose 草稿</li>
<li>· 授权后声纹克隆</li>
<li>· Drift 留白长会话</li>
<li>· 语音备忘 · 有声书导出</li>
<li>· 可选加密备份</li>
</ul>
<div class="price-card__cta" data-hover>走进来</div>
</div>
<div class="price-card">
<div class="price-card__name"><em>Grove</em>林者</div>
<div class="price-card__tag">为工作室 / 伴侣共享</div>
<div class="price-card__amt"><sup>¥</sup>288</div>
<div class="price-card__per">每月 · 至多八个声纹</div>
<ul class="price-card__feat">
<li>· 包含森者全部</li>
<li>· 共享 Grove 口信</li>
<li>· 单声纹授权账本</li>
<li>· 工作室级留存策略</li>
<li>· 现场欢迎仪式</li>
<li>· 直通创作团队</li>
</ul>
<div class="price-card__cta" data-hover>谈一谈</div>
</div>
</div>
<p style="margin-top: 80px; font-size: 13px; color: var(--ink-mute); max-width: 680px; line-height: 1.85;">
我们按"声纹"计费,从不按"次"。没有用量上限。没有 enterprise 版本。如果你需要超出 Grove 的安排,写信给我们,我们一起想。
</p>
<div class="page__foot">
<span>© 2026 Aeria Studio · 增值税号 NO 928 411 552</span>
<span>价格 · 2026 年 5 月</span>
</div>
</div>
</div>
<!-- CONTACT -->
<div class="page" id="page-contact">
<div class="page__inner">
<div class="page__nav">
<a class="page__back" href="#" data-back data-hover>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回林间
</a>
<span class="page__crumb">Aeria · Contact</span>
</div>
<div class="page__hero">
<div class="page__eyebrow">— 轻声说话</div>
<h1 class="page__title">写信给我们<br/>我们<em>一定</em>回</h1>
<p class="page__lede">内测申请、合作、媒体邀约,或一封你早就想寄给陌生人的长信。我们三人会读完所有来信。</p>
</div>
<div class="contact-grid">
<form class="contact-form" id="contact-form">
<input type="text" placeholder="您的名字" required data-hover />
<input type="email" placeholder="可以回信的邮箱" required data-hover />
<input type="text" placeholder="主题(可选)" data-hover />
<textarea placeholder="想对我们说些什么?" required data-hover></textarea>
<button type="submit" data-hover>寄出这封信</button>
</form>
<div class="contact-info">
<h4><em>Studio</em>工作室</h4>
<p>
Aeria Studio AS<br/>
Bryggen 41 · 5003 Bergen<br/>
挪威
</p>
<h4><em>Direct</em>直接写信</h4>
<p>
<a href="mailto:hello@aeria.studio" data-hover>hello@aeria.studio</a><br/>
<a href="mailto:press@aeria.studio" data-hover>press@aeria.studio</a><br/>
<a href="mailto:beta@aeria.studio" data-hover>beta@aeria.studio</a>
</p>
<h4><em>Hours</em>工作时段</h4>
<p>我们在中欧时区周二至周五 09:00–16:00 之间回信。<br/>星期一用来散步。</p>
</div>
</div>
<div class="page__foot">
<span>© 2026 Aeria Studio</span>
<span>所有来信 · 必复</span>
</div>
</div>
</div>
<!-- ============== THREE.JS ============== -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
/* ---------- renderer / scene ---------- */
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.35;
const scene = new THREE.Scene();
const fogColor = new THREE.Color(0x2a2a20); // 暖灰雾,不是死黑
scene.background = fogColor;
scene.fog = new THREE.FogExp2(fogColor.getHex(), 0.0028);
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 3000);
camera.position.set(0, 4.6, 60);
camera.rotation.order = 'YXZ';
/* ---------- gradient sky dome ---------- */
let skyDome;
{
const skyGeo = new THREE.SphereGeometry(2400, 32, 24);
const skyMat = new THREE.MeshBasicMaterial({ side: THREE.BackSide, fog: false, vertexColors: true });
const top = new THREE.Color(0x141a18);
const upper = new THREE.Color(0x2a2c22);
const horiz = new THREE.Color(0x5a4a30); // 暖橙水平线
const lower = new THREE.Color(0x1c1e16);
const cols = [];
const pos = skyGeo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const y = pos.getY(i) / 2400; // -1..1
let c;
if (y > 0.4) c = top.clone().lerp(upper, (1 - y)/0.6);
else if (y > 0.0) c = upper.clone().lerp(horiz, 1 - y/0.4);
else if (y > -0.3) c = horiz.clone().lerp(lower, -y/0.3);
else c = lower.clone();
cols.push(c.r, c.g, c.b);
}
skyGeo.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3));
skyDome = new THREE.Mesh(skyGeo, skyMat);
scene.add(skyDome);
}
/* ---------- lights ---------- */
scene.add(new THREE.HemisphereLight(0xd6e0bd, 0x1a1812, 0.95));
const sun = new THREE.DirectionalLight(0xffe2b0, 1.95);
sun.position.set(140, 220, -180);
scene.add(sun);
const fill = new THREE.DirectionalLight(0x6da6ff, 0.30);
fill.position.set(-120, 100, 80);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xffd896, 0.85);
rim.position.set(0, 30, -300);
scene.add(rim);
/* ---------- ground ---------- */
const groundCanvas = (() => {
const c = document.createElement('canvas');
c.width = c.height = 512;
const ctx = c.getContext('2d');
// base
ctx.fillStyle = '#161c14';
ctx.fillRect(0,0,512,512);
// soft mossy patches (slightly lighter dark green)
for (let i = 0; i < 1400; i++) {
const x = Math.random()*512, y = Math.random()*512;
const r = Math.random()*8 + 1;
const a = Math.random()*0.22;
ctx.fillStyle = `rgba(${30+Math.random()*50|0},${50+Math.random()*40|0},${30+Math.random()*30|0},${a})`;
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
}
// darker undertones (depth)
for (let i = 0; i < 200; i++) {
const x = Math.random()*512, y = Math.random()*512;
const r = Math.random()*40 + 10;
const grd = ctx.createRadialGradient(x,y,1,x,y,r);
grd.addColorStop(0, 'rgba(0,0,0,0.35)');
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grd;
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
}
// subtle warm flecks (fallen leaves)
for (let i = 0; i < 80; i++) {
const x = Math.random()*512, y = Math.random()*512;
const r = Math.random()*1.5 + 0.5;
ctx.fillStyle = `rgba(${130+Math.random()*40|0},${90+Math.random()*30|0},${50+Math.random()*20|0},0.45)`;
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
}
return c;
})();
const groundTex = new THREE.CanvasTexture(groundCanvas);
groundTex.wrapS = groundTex.wrapT = THREE.RepeatWrapping;
groundTex.repeat.set(40, 40);
groundTex.colorSpace = THREE.SRGBColorSpace;
groundTex.anisotropy = renderer.capabilities.getMaxAnisotropy();
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(4000, 4000, 1, 1),
new THREE.MeshStandardMaterial({
color: 0x1a2018, map: groundTex, roughness: 1, metalness: 0
})
);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
/* ---------- bark texture (procedural canvas — birch-like) ---------- */
const barkCanvas = (() => {
const c = document.createElement('canvas');
c.width = 128; c.height = 512;
const ctx = c.getContext('2d');
// light cream base (birch-like)
ctx.fillStyle = '#d8cdb4';
ctx.fillRect(0,0,128,512);
// soft vertical streaks (lighter)
for (let i = 0; i < 60; i++) {
const x = Math.random()*128;
const w = 0.5 + Math.random()*1.8;
const a = 0.06 + Math.random()*0.12;
ctx.fillStyle = `rgba(255,245,220,${a})`;
ctx.fillRect(x, 0, w, 512);
}
// horizontal scars — iconic birch dark stripes
for (let i = 0; i < 36; i++) {
const y = Math.random()*512;
const h = 0.6 + Math.random()*2.2;
const a = 0.45 + Math.random()*0.35;
ctx.fillStyle = `rgba(30,22,15,${a})`;
ctx.fillRect(0, y, 128, h);
}
// dark scar dots (knots)
for (let i = 0; i < 14; i++) {
const x = Math.random()*128, y = Math.random()*512;
const r = 1 + Math.random()*3;
ctx.fillStyle = `rgba(30,22,15,${0.55+Math.random()*0.3})`;
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
}
// subtle vertical shadow on edges (rounding)
const grd = ctx.createLinearGradient(0,0,128,0);
grd.addColorStop(0, 'rgba(40,30,20,0.35)');
grd.addColorStop(0.5, 'rgba(40,30,20,0)');
grd.addColorStop(1, 'rgba(40,30,20,0.35)');
ctx.fillStyle = grd;
ctx.fillRect(0,0,128,512);
return c;
})();
const barkTex = new THREE.CanvasTexture(barkCanvas);
barkTex.wrapS = THREE.RepeatWrapping;
barkTex.wrapT = THREE.RepeatWrapping;
barkTex.colorSpace = THREE.SRGBColorSpace;
/* ---------- forest (instanced trunks) ---------- */
const TRUNK_COUNT = 620;
const trunkGeo = new THREE.CylinderGeometry(0.16, 0.28, 24, 8, 1, false);
trunkGeo.translate(0, 12, 0);
const trunkMat = new THREE.MeshStandardMaterial({
color: 0xffffff,
map: barkTex,
roughness: 0.94, metalness: 0
});
const trunks = new THREE.InstancedMesh(trunkGeo, trunkMat, TRUNK_COUNT);
trunks.instanceMatrix.setUsage(THREE.StaticDrawUsage);
const dummy = new THREE.Object3D();
const PATH_LENGTH = 2400;
function rand(seed) { return Math.abs(Math.sin(seed*9301+49297)) % 1; }
const colorOpts = [
new THREE.Color(0xddd4be),
new THREE.Color(0xc8bca0),
new THREE.Color(0xe5dcc6),
new THREE.Color(0xb8a888),
new THREE.Color(0xd0c4a8),
];
for (let i = 0; i < TRUNK_COUNT; i++) {
const t = i / TRUNK_COUNT;
const z = -t * PATH_LENGTH - rand(i)*10;
const curve = Math.sin(z * 0.0018) * 6;
let x = (rand(i+9)*2-1) * 90 + curve;
if (Math.abs(x - curve) < 5.5) {
x = curve + (x > curve ? 6 + rand(i+22)*5 : -6 - rand(i+22)*5);
}
dummy.position.set(x, 0, z);
dummy.rotation.set((rand(i+3)-0.5)*0.06, rand(i+7)*Math.PI*2, (rand(i+11)-0.5)*0.06);
const sx = 0.55 + rand(i+5)*1.4;
const sy = 0.6 + rand(i+13)*1.6;
dummy.scale.set(sx, sy, sx);
dummy.updateMatrix();
trunks.setMatrixAt(i, dummy.matrix);
trunks.setColorAt(i, colorOpts[i % colorOpts.length].clone().multiplyScalar(0.85 + rand(i+33)*0.4));
}
trunks.instanceMatrix.needsUpdate = true;
if (trunks.instanceColor) trunks.instanceColor.needsUpdate = true;
scene.add(trunks);
/* ---------- canopy (dark blobs above for ceiling feel) ---------- */
const canopyMat = new THREE.MeshStandardMaterial({
color: 0x0e160e, roughness: 1, metalness: 0,
transparent: true, opacity: 0.95
});
const canopyGeo = new THREE.IcosahedronGeometry(14, 1);
const CANOPY_COUNT = 70;
const canopy = new THREE.InstancedMesh(canopyGeo, canopyMat, CANOPY_COUNT);
for (let i = 0; i < CANOPY_COUNT; i++) {
dummy.position.set(
(rand(i+101)*2-1)*70,
26 + rand(i+103)*10,
-i * (PATH_LENGTH / CANOPY_COUNT) - rand(i+105)*40
);
const s = 1.8 + rand(i+107)*2.2;
dummy.scale.set(s*1.5, s*0.7, s*1.5);
dummy.rotation.set(rand(i)*Math.PI, rand(i+5)*Math.PI, rand(i+9)*Math.PI);
dummy.updateMatrix();
canopy.setMatrixAt(i, dummy.matrix);
}
scene.add(canopy);
/* ---------- ferns / undergrowth (lighter green dots) ---------- */
const fernCount = 1800;
const fernGeo = new THREE.BufferGeometry();
const fernPos = new Float32Array(fernCount*3);
const fernCol = new Float32Array(fernCount*3);
for (let i = 0; i < fernCount; i++) {
fernPos[i*3] = (rand(i+200)*2-1)*70 + Math.sin(-i*4*0.0018)*6;
fernPos[i*3+1] = 0.25 + rand(i+201)*0.8;
fernPos[i*3+2] = -rand(i+202)*PATH_LENGTH;
const g = 0.42 + rand(i+205)*0.28;
fernCol[i*3] = 0.28 + rand(i+206)*0.18;
fernCol[i*3+1] = g;
fernCol[i*3+2] = 0.20;
}
fernGeo.setAttribute('position', new THREE.BufferAttribute(fernPos, 3));
fernGeo.setAttribute('color', new THREE.BufferAttribute(fernCol, 3));
const fernMat = new THREE.PointsMaterial({
size: 0.55, sizeAttenuation: true,
vertexColors: true,
transparent: true, opacity: 0.95
});
scene.add(new THREE.Points(fernGeo, fernMat));
/* ---------- rocks scattered along path ---------- */
const rockGeo = new THREE.DodecahedronGeometry(1, 0);
const rockMat = new THREE.MeshStandardMaterial({
color: 0x6c6553, roughness: 0.95, metalness: 0
});
const ROCK_COUNT = 90;
const rocks = new THREE.InstancedMesh(rockGeo, rockMat, ROCK_COUNT);
for (let i = 0; i < ROCK_COUNT; i++) {
const z = -rand(i+401)*PATH_LENGTH;
const curve = Math.sin(z * 0.0018) * 6;
let x = (rand(i+402)*2-1) * 50 + curve;
if (Math.abs(x - curve) < 4) x += (x > curve ? 6 : -6);
const sx = 0.6 + rand(i+403)*1.6;
dummy.position.set(x, sx*0.4, z);
dummy.rotation.set(rand(i)*Math.PI, rand(i+1)*Math.PI, rand(i+2)*Math.PI);
dummy.scale.set(sx, sx*0.7, sx*1.1);
dummy.updateMatrix();
rocks.setMatrixAt(i, dummy.matrix);
const tone = 0.7 + rand(i+404)*0.6;
rocks.setColorAt(i, new THREE.Color(0x7a7261).multiplyScalar(tone));
}
if (rocks.instanceColor) rocks.instanceColor.needsUpdate = true;
scene.add(rocks);
/* ---------- fallen logs ---------- */
const logGeo = new THREE.CylinderGeometry(0.5, 0.55, 6, 8);
const logMat = new THREE.MeshStandardMaterial({
color: 0x5a4a36, map: barkTex, roughness: 0.95, metalness: 0
});
const LOG_COUNT = 14;
const logs = new THREE.InstancedMesh(logGeo, logMat, LOG_COUNT);
for (let i = 0; i < LOG_COUNT; i++) {
const z = -200 - rand(i+501) * (PATH_LENGTH-300);
const curve = Math.sin(z * 0.0018) * 6;
let x = (rand(i+502)*2-1) * 28 + curve;
if (Math.abs(x - curve) < 6) x += (x > curve ? 8 : -8);
dummy.position.set(x, 0.5, z);
dummy.rotation.set(0, rand(i+503)*Math.PI*2, Math.PI/2);
const sl = 0.7 + rand(i+504)*1.2;
dummy.scale.set(sl*0.9, sl, sl*0.9);
dummy.updateMatrix();
logs.setMatrixAt(i, dummy.matrix);
}
scene.add(logs);
/* ---------- distant tree silhouettes (cheap depth layer) ---------- */
const sillGeo = new THREE.PlaneGeometry(1, 1);
const sillTex = (() => {
const c = document.createElement('canvas');
c.width = 256; c.height = 256;
const ctx = c.getContext('2d');
ctx.clearRect(0,0,256,256);
// draw silhouette tree
ctx.fillStyle = '#000';
// trunk
ctx.fillRect(120, 130, 16, 126);
// foliage layers
ctx.beginPath();
ctx.moveTo(128, 10);
ctx.lineTo(60, 140);
ctx.lineTo(196, 140);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(128, 50);
ctx.lineTo(40, 180);
ctx.lineTo(216, 180);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(128, 90);
ctx.lineTo(20, 220);
ctx.lineTo(236, 220);
ctx.closePath(); ctx.fill();
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
})();
const sillMat = new THREE.MeshBasicMaterial({
map: sillTex, transparent: true, color: 0x121613, depthWrite: false
});
const SILL_COUNT = 40;
for (let i = 0; i < SILL_COUNT; i++) {
const m = new THREE.Mesh(sillGeo, sillMat);
const z = -600 - i * ((PATH_LENGTH-300)/SILL_COUNT) - rand(i+601)*40;
m.position.set(
(rand(i+602)*2-1) * 160,
18,
z
);
const s = 36 + rand(i+603)*36;
m.scale.set(s*0.8, s, 1);
scene.add(m);
}
/* ---------- fireflies (warm glowing points) ---------- */
const FIRE = 80;
const fireGeo = new THREE.BufferGeometry();
const firePos = new Float32Array(FIRE*3);
const fireBase = [];
for (let i = 0; i < FIRE; i++) {
const z = -100 - rand(i+701)*PATH_LENGTH;
const curve = Math.sin(z * 0.0018) * 6;
firePos[i*3] = (rand(i+702)*2-1)*30 + curve;
firePos[i*3+1] = 1 + rand(i+703)*5;
firePos[i*3+2] = z;
fireBase.push({ y: firePos[i*3+1], phase: rand(i+704)*Math.PI*2 });
}
fireGeo.setAttribute('position', new THREE.BufferAttribute(firePos, 3));
const fireMat = new THREE.PointsMaterial({
color: 0xffd66b, size: 0.55, sizeAttenuation: true,
transparent: true, opacity: 0.85,
blending: THREE.AdditiveBlending, depthWrite: false
});
const fireflies = new THREE.Points(fireGeo, fireMat);
scene.add(fireflies);
/* ---------- floating dust / pollen (warm bright) ---------- */
const DUST = 2400;
const dustGeo = new THREE.BufferGeometry();
const dustPos = new Float32Array(DUST*3);
for (let i = 0; i < DUST; i++) {
dustPos[i*3] = (Math.random()-0.5)*200;
dustPos[i*3+1] = Math.random()*32;
dustPos[i*3+2] = -Math.random()*PATH_LENGTH;
}
dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPos, 3));
const dustMat = new THREE.PointsMaterial({
color: 0xffe8b8, size: 0.16, sizeAttenuation: true,
transparent: true, opacity: 0.55,
blending: THREE.AdditiveBlending, depthWrite: false
});
const dust = new THREE.Points(dustGeo, dustMat);
scene.add(dust);
/* ---------- god rays (additive planes) ---------- */
const rayGroup = new THREE.Group();
const rayTex = (() => {
const c = document.createElement('canvas');
c.width = 64; c.height = 256;
const ctx = c.getContext('2d');
const g = ctx.createLinearGradient(0,0,0,256);
g.addColorStop(0, 'rgba(255,238,190,1)');
g.addColorStop(0.45, 'rgba(255,228,170,0.28)');
g.addColorStop(1, 'rgba(255,228,170,0)');
ctx.fillStyle = g; ctx.fillRect(0,0,64,256);
const h = ctx.createLinearGradient(0,0,64,0);
h.addColorStop(0, 'rgba(0,0,0,1)');
h.addColorStop(0.5, 'rgba(0,0,0,0)');
h.addColorStop(1, 'rgba(0,0,0,1)');
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = h;
ctx.fillRect(0,0,64,256);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
})();
for (let i = 0; i < 30; i++) {
const m = new THREE.Mesh(
new THREE.PlaneGeometry(20, 110),
new THREE.MeshBasicMaterial({
map: rayTex,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
opacity: 0.32 + Math.random()*0.30,
side: THREE.DoubleSide,
fog: false
})
);
m.position.set(
(Math.random()-0.5)*80,
36,
-100 - i * 80 + Math.random()*60
);
m.rotation.z = (Math.random()-0.5)*0.55;
m.rotation.y = (Math.random()-0.5)*0.35;
rayGroup.add(m);
}
scene.add(rayGroup);
/* ---------- distant glow (sun behind trees) ---------- */
const glow = new THREE.Mesh(
new THREE.PlaneGeometry(900, 900),
new THREE.MeshBasicMaterial({
map: (() => {
const c = document.createElement('canvas');
c.width = c.height = 256;
const g = c.getContext('2d').createRadialGradient(128,128,4,128,128,128);
g.addColorStop(0, 'rgba(255,240,200,1)');
g.addColorStop(0.35, 'rgba(255,212,160,0.5)');
g.addColorStop(1, 'rgba(0,0,0,0)');
const ctx = c.getContext('2d');
ctx.fillStyle = g; ctx.fillRect(0,0,256,256);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
})(),
transparent: true, depthWrite: false,
blending: THREE.AdditiveBlending
})
);
glow.position.set(60, 100, -PATH_LENGTH - 200);
scene.add(glow);
/* ---------- resize ---------- */
function resize() {
const w = window.innerWidth, h = window.innerHeight;
renderer.setSize(w, h, false);
camera.aspect = w/h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();
/* ---------- input ---------- */
let progress = 0, progressTarget = 0;
let mouseX = 0, mouseY = 0;
let yaw = 0, pitch = 0;
let yawT = 0, pitchT = 0;
let interacted = false;
window.addEventListener('mousemove', (e) => {
mouseX = (e.clientX / window.innerWidth - 0.5);
mouseY = (e.clientY / window.innerHeight - 0.5);
});
const SCROLL_GAIN = 0.00026; // 比之前慢 60%
const TOUCH_GAIN = 0.00075;
const KEY_STEP = 0.035;
const SMOOTH = 0.038; // 相机插值系数(更慢更顺)
window.addEventListener('wheel', (e) => {
if (document.body.classList.contains('page-open')) return;
progressTarget = Math.max(0, Math.min(1, progressTarget + e.deltaY * SCROLL_GAIN));
interacted = true;
}, { passive: true });
let touchStart = null;
window.addEventListener('touchstart', (e) => {
if (document.body.classList.contains('page-open')) return;
touchStart = e.touches[0].clientY;
}, { passive: true });
window.addEventListener('touchmove', (e) => {
if (touchStart === null || document.body.classList.contains('page-open')) return;
const dy = touchStart - e.touches[0].clientY;
progressTarget = Math.max(0, Math.min(1, progressTarget + dy * TOUCH_GAIN));
touchStart = e.touches[0].clientY;
interacted = true;
}, { passive: true });
window.addEventListener('touchend', () => { touchStart = null; });
window.addEventListener('keydown', (e) => {
if (document.body.classList.contains('page-open')) return;
if (e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ') {
progressTarget = Math.min(1, progressTarget + KEY_STEP);
interacted = true;
}
if (e.key === 'ArrowUp' || e.key === 'PageUp') {
progressTarget = Math.max(0, progressTarget - KEY_STEP);
interacted = true;
}
});
/* ---------- station show/hide ---------- */
const stationEls = Array.from(document.querySelectorAll('.station'));
function updateStations() {
stationEls.forEach((el) => {
const from = parseFloat(el.dataset.from);
const to = parseFloat(el.dataset.to);
el.classList.toggle('active', progress >= from && progress < to);
});
}
/* ---------- chapter dots ---------- */
const chapterEls = Array.from(document.querySelectorAll('.chapters__item'));
chapterEls.forEach((el) => {
el.addEventListener('click', () => {
progressTarget = parseFloat(el.dataset.go);
interacted = true;
});
});
function updateChapters() {
let activeIdx = 0;
for (let i = 0; i < chapterEls.length; i++) {
if (progress >= parseFloat(chapterEls[i].dataset.go) - 0.05) activeIdx = i;
}
chapterEls.forEach((el, i) => el.classList.toggle('active', i === activeIdx));
}
/* ---------- progress UI ---------- */
const progFill = document.getElementById('prog-fill');
const progNum = document.getElementById('prog-num');
const hint = document.getElementById('hint');
/* ---------- camera path ---------- */
const startZ = 60, endZ = -PATH_LENGTH + 240;
function tick() {
// smoothing
progress += (progressTarget - progress) * SMOOTH;
yawT = -mouseX * 0.38;
pitchT = -mouseY * 0.20;
yaw += (yawT - yaw) * 0.06;
pitch += (pitchT - pitch) * 0.06;
const z = startZ + (endZ - startZ) * progress;
camera.position.z = z;
camera.position.x = Math.sin(z * 0.0018) * 6 * -1;
camera.position.y = 4.6 + Math.sin(progress * Math.PI * 2.0) * 0.4;
camera.rotation.y = yaw;
camera.rotation.x = pitch;
// sky dome follows camera so the gradient is always around the viewer
skyDome.position.set(camera.position.x, 0, camera.position.z);
// dust drift
const t = performance.now() * 0.0001;
dust.rotation.y = t * 0.3;
dust.position.y = Math.sin(t*5) * 0.4;
// fireflies bob
const fp = fireflies.geometry.attributes.position;
for (let i = 0; i < fireBase.length; i++) {
fp.array[i*3+1] = fireBase[i].y + Math.sin(t*40 + fireBase[i].phase) * 0.6;
}
fp.needsUpdate = true;
fireflies.material.opacity = 0.65 + Math.sin(t*30) * 0.2;
// ray sway
rayGroup.children.forEach((r, i) => {
r.rotation.z = Math.sin(t * 5 + i) * 0.08 + (i*0.05-0.5);
r.material.opacity = 0.30 + Math.sin(t*4 + i*1.3) * 0.12;
});
// UI
updateStations();
updateChapters();
const pct = Math.round(progress*1000);
progFill.style.width = (progress*100) + '%';
progNum.textContent = String(pct).padStart(3, '0');
if (interacted) hint.classList.add('fade');
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
tick();
/* ============== UI: cursor ============== */
const cursor = document.getElementById('cursor');
let cx = 0, cy = 0, tcx = 0, tcy = 0;
window.addEventListener('mousemove', (e) => { tcx = e.clientX; tcy = e.clientY; });
function cursorTick() {
cx += (tcx - cx) * 0.22;
cy += (tcy - cy) * 0.22;
cursor.style.transform = `translate(${cx}px, ${cy}px) translate(-50%, -50%)`;
requestAnimationFrame(cursorTick);
}
cursorTick();
document.querySelectorAll('[data-hover]').forEach(el => {
el.addEventListener('mouseenter', () => cursor.classList.add('hover'));
el.addEventListener('mouseleave', () => cursor.classList.remove('hover'));
});
window.addEventListener('mousedown', () => cursor.classList.add('click'));
window.addEventListener('mouseup', () => cursor.classList.remove('click'));
/* ============== loader ============== */
const loader = document.getElementById('loader');
document.getElementById('enter').addEventListener('click', () => {
loader.classList.add('gone');
});
setTimeout(() => loader.classList.add('gone'), 8000);
/* ============== sub-page routing ============== */
const pages = {
features: document.getElementById('page-features'),
story: document.getElementById('page-story'),
pricing: document.getElementById('page-pricing'),
contact: document.getElementById('page-contact')
};
function openPage(name) {
Object.values(pages).forEach(p => p.classList.remove('open'));
if (pages[name]) {
pages[name].classList.add('open');
pages[name].scrollTop = 0;
document.body.classList.add('page-open');
} else {
document.body.classList.remove('page-open');
}
}
function readHash() {
const h = (location.hash || '').replace('#', '').trim();
openPage(h);
}
window.addEventListener('hashchange', readHash);
readHash();
document.querySelectorAll('[data-back]').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
history.pushState(null, '', '#');
openPage('');
});
});
document.querySelectorAll('.cta[href^="#"]').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
const target = el.getAttribute('href');
history.pushState(null, '', target);
readHash();
});
});
/* ============== contact form ============== */
const form = document.getElementById('contact-form');
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
const btn = form.querySelector('button');
btn.textContent = '已寄出 · 我们会回信';
btn.disabled = true;
btn.style.opacity = '0.6';
});
}
</script>
</body>
</html>