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

1233 lines
47 KiB
HTML
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- SWAP-1 标题 -->
<title>{{COLLECTION_TITLE}} — {{COLLECTION_SUB}}</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=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@300;400;500&family=JetBrains+Mono:wght@400;500&family=Noto+Serif+SC:wght@400;500&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
<style>
/* ===================== design tokens — 冷调钢蓝工业 ===================== */
/* SWAP-STYLE 这 8 个变量决定整站调性,换风格只改这里 */
:root{
--bg: #e6e9ee;
--bg-soft: #f1f3f6;
--paper: #c9cfd6;
--ink: #0c0f14;
--ink-soft: #535963;
--line: #aab0b8;
--line-soft: #c4c9d0;
--steel: #3a4a5a;
--steel-dim: #6a7684;
--serif: 'Cormorant Garamond', 'Noto Serif SC', 'Songti SC', 'STSong', serif;
--sans: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, sans-serif;
--mono: 'JetBrains Mono', ui-monospace, monospace;
--text-xs: 10.5px;
--text-s: 12px;
--text-m: 14px;
--text-l: 28px;
--text-xl: 56px;
}
*{ margin:0; padding:0; box-sizing:border-box; }
html, body{
width:100%; height:100%;
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
font-size: var(--text-m);
letter-spacing: 0.005em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body{
overflow:hidden;
background-image:
radial-gradient(at 20% 22%, rgba(170,176,184,0.22) 0px, transparent 50%),
radial-gradient(at 78% 80%, rgba(58,74,90,0.07) 0px, transparent 55%);
}
body.detail-open{ overflow-y:auto; }
::selection{ background: var(--steel); color: var(--bg-soft); }
a{ color: inherit; text-decoration: none; }
button{ all: unset; cursor: pointer; }
.page-title, h1{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: var(--text-l); letter-spacing: 0.01em; line-height: 1.1;
}
h2{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: var(--text-xl); letter-spacing: 0.01em; line-height: 1.02;
}
.label{
font-family: var(--mono); font-size: var(--text-xs);
letter-spacing: 0.14em; text-transform: uppercase;
color: var(--ink-soft); line-height: 1.55;
}
.label--ink{ color: var(--ink); }
.serif-tag{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: 17px; letter-spacing: 0.01em; line-height: 1.35;
}
/* ===================== ticker ===================== */
.ticker{
position: fixed; top: 0; left: 0; right: 0;
height: 22px; z-index: 4;
border-bottom: 1px solid var(--line);
background: var(--bg-soft);
display: flex; align-items: center; overflow: hidden;
font-family: var(--mono); font-size: 10px; letter-spacing: 0.18em;
color: var(--ink-soft); text-transform: uppercase;
}
.ticker-track{
display:flex; gap: 36px;
animation: ticker 70s linear infinite;
padding-left: 100%; white-space: nowrap;
}
@keyframes ticker{
0%{ transform: translateX(0); }
100%{ transform: translateX(-100%); }
}
.ticker .dot{ color: var(--steel); }
/* ===================== grid ===================== */
.stage{
position: fixed; inset: 0;
padding-top: 44px;
display: grid;
grid-template-columns: 280px 1fr 280px;
grid-template-rows: 64px 1fr 64px;
gap: 22px;
padding: 44px 26px 22px;
z-index: 5;
}
.g1c1{ grid-column:1; grid-row:1; }
.g1c2{ grid-column:2; grid-row:1; }
.g1c3{ grid-column:3; grid-row:1; }
.g2c1{ grid-column:1; grid-row:2; }
.g2c2{ grid-column:2; grid-row:2; }
.g2c3{ grid-column:3; grid-row:2; }
.g3c1{ grid-column:1; grid-row:3; }
.g3c2{ grid-column:2; grid-row:3; }
.g3c3{ grid-column:3; grid-row:3; }
/* header */
.head-mark{ display:flex; flex-direction:column; gap:4px; }
.head-mark .glyph{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: 32px; line-height: 1; letter-spacing: 0.01em; color: var(--ink);
}
.head-center{ display:flex; flex-direction:column; align-items:center; gap:4px; padding-top:4px; }
.head-right{ display:flex; flex-direction:column; align-items:flex-end; gap:4px; }
.nav-btn{
display:inline-flex; align-items:center; gap:8px;
padding: 7px 12px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.3);
transition: border-color .35s, color .35s, background .35s;
color: var(--ink);
}
.nav-btn:hover{ border-color: var(--ink); background: var(--ink); color: var(--bg-soft); }
.nav-btn .arr{ font-family: var(--mono); font-size: 13px; }
.nav-btn .txt{
font-family: var(--mono); font-size: var(--text-xs);
letter-spacing: 0.14em; text-transform: uppercase;
}
/* sidebar */
.sidebar{ display:flex; flex-direction:column; justify-content:center; position:relative; }
.sidebar-head{ margin-bottom: 18px; }
.sidebar-head .rule{ width: 28px; height: 1px; background: var(--ink); margin-top: 8px; }
.proj-list{ list-style: none; position: relative; padding-left: 14px; }
.indicator{
position: absolute;
left: 0; top: 0;
width: 2px; height: 0;
background: var(--steel);
transition: top .55s cubic-bezier(.2,.8,.2,1), height .55s cubic-bezier(.2,.8,.2,1);
}
.proj-list li{
position: relative;
padding: 7px 0;
cursor: pointer;
color: var(--ink-soft);
transition: color .5s cubic-bezier(.2,.8,.2,1),
transform .55s cubic-bezier(.2,.8,.2,1),
padding-left .45s cubic-bezier(.2,.8,.2,1);
display:flex; align-items: baseline; gap: 12px;
}
.proj-list li .num{
font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em;
color: var(--ink-soft); min-width: 36px;
transition: color .4s;
}
.proj-list li .ttl{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: var(--text-l); letter-spacing: 0.01em; line-height: 1.12;
display:inline-block; position:relative;
}
.proj-list li .ttl::after{
content:""; position:absolute; left:0; bottom: 2px;
width: 0; height: 1px; background: var(--ink);
transition: width .55s cubic-bezier(.2,.8,.2,1);
}
.proj-list li:hover{ color: var(--ink); padding-left: 6px; }
.proj-list li:hover .num{ color: var(--ink); }
.proj-list li:hover .ttl::after{ width: 100%; }
.proj-list li.active{ color: var(--ink); padding-left: 14px; }
.proj-list li.active .num{ color: var(--steel); }
.proj-list li.active .ttl::after{ width: 100%; background: var(--steel); }
.proj-list li .ttl .ch{ display: inline-block; }
.proj-list li.active .ttl .ch{
animation: shimmer 2.8s ease-in-out infinite;
animation-delay: calc(var(--i,0) * 80ms);
}
@keyframes shimmer{
0%, 92%, 100%{ transform: translateY(0); opacity: 1; }
96%{ transform: translateY(-2px); opacity: .55; }
}
/* viewport (cover) */
.viewport{
position: relative; overflow: visible;
background: #0a0c11;
cursor: pointer;
z-index: 10;
box-shadow:
inset 0 0 0 1px rgba(170,176,184,0.22),
0 30px 60px -28px rgba(12,15,20,0.45);
}
.viewport::before{
content: ""; position: absolute; inset: 0; z-index: 1;
box-shadow: inset 0 0 140px rgba(0,0,0,0.85);
pointer-events: none;
}
.viewport::after{
content: ""; position: absolute; inset: 0; z-index: 2;
pointer-events: none; opacity: 0.08;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' 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.5 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
mix-blend-mode: screen;
}
.viewport-mask{
position: absolute; inset: 0; z-index: 20;
overflow: hidden;
}
.viewport.switching .viewport-mask{ overflow: visible; }
.cover-stage{
position: absolute; inset: 0;
perspective: 600px;
perspective-origin: 50% 50%;
transform-style: preserve-3d;
}
.cover-flipper{
position: absolute; inset: 0;
transform-style: preserve-3d;
transform: translateZ(0) rotateY(0deg);
transition: transform 1.05s cubic-bezier(.55,.05,.25,1);
will-change: transform;
opacity: 1;
}
.cover-face{
position: absolute; inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
background-color: #b0b6be;
background-image: linear-gradient(180deg, #bcc2ca 0%, #a4abb4 100%);
overflow: hidden;
opacity: 1;
isolation: isolate;
box-shadow:
inset 0 0 0 1px rgba(12,15,20,0.32),
inset 0 -60px 80px -40px rgba(12,15,20,0.22),
0 28px 60px -22px rgba(12,15,20,0.55);
}
.cover-face.front{ transform: rotateY(0deg); }
.cover-face.back { transform: rotateY(180deg); }
.cover-face img{
display: block;
width: 100%; height: 100%;
object-fit: cover;
filter: contrast(1.05) saturate(0.85);
}
.cover-face .frame-marks{
position:absolute; inset:0; pointer-events:none;
z-index: 5;
}
.cover-face .frame-marks span{
position:absolute; font-family: var(--mono); font-size:10px;
letter-spacing:0.18em; color: rgba(241,243,246,0.78);
}
.cover-face .frame-marks .ft{ top:14px; left:18px; }
.cover-face .frame-marks .fr{ top:14px; right:18px; }
.cover-face .frame-marks .fl{ bottom:14px; left:18px; }
.cover-face .frame-marks .fb{ bottom:14px; right:18px; }
.cover-face::after{
content:""; position:absolute; inset:0;
background:
linear-gradient(180deg, rgba(0,0,0,0.0) 55%, rgba(0,0,0,0.55) 100%),
linear-gradient(0deg, rgba(0,0,0,0.0) 70%, rgba(0,0,0,0.35) 100%);
pointer-events:none;
}
.cover-caption{
position: absolute; left: 24px; bottom: 24px; right: 24px;
z-index: 30;
color: var(--bg-soft);
display:flex; justify-content: space-between; align-items: flex-end;
gap: 24px;
pointer-events: none;
}
.cover-caption .lhs{ max-width: 70%; }
.cover-caption .ch{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: clamp(36px, 5vw, 64px); line-height: 1; letter-spacing: 0.01em;
text-shadow: 0 4px 28px rgba(0,0,0,0.5);
transition: opacity .45s, transform .55s cubic-bezier(.2,.8,.2,1);
}
.cover-caption .en{
margin-top: 8px;
font-family: var(--mono); font-size: var(--text-xs);
letter-spacing: 0.2em; text-transform: uppercase;
color: rgba(241,243,246,0.9);
transition: opacity .45s, transform .55s cubic-bezier(.2,.8,.2,1);
transition-delay: .08s;
}
.cover-caption .rhs{
text-align: right;
font-family: var(--mono); font-size: var(--text-xs);
letter-spacing: 0.14em; text-transform: uppercase;
color: rgba(241,243,246,0.85); line-height: 1.7;
transition: opacity .45s, transform .55s cubic-bezier(.2,.8,.2,1);
transition-delay: .16s;
}
.cover-caption.swap .ch,
.cover-caption.swap .en,
.cover-caption.swap .rhs{ opacity: 0; transform: translateY(8px); }
.bracket{ position: absolute; width: 14px; height: 14px; z-index: 40; pointer-events:none; }
.bracket--tl{ top: 12px; left: 12px; border-top:1px solid var(--bg-soft); border-left:1px solid var(--bg-soft); }
.bracket--tr{ top: 12px; right:12px; border-top:1px solid var(--bg-soft); border-right:1px solid var(--bg-soft); }
.bracket--bl{ bottom:12px; left: 12px; border-bottom:1px solid var(--bg-soft); border-left:1px solid var(--bg-soft); }
.bracket--br{ bottom:12px; right:12px; border-bottom:1px solid var(--bg-soft); border-right:1px solid var(--bg-soft); }
.enter-cue{
position: absolute; top: 18px; right: 18px; z-index: 40;
font-family: var(--mono); font-size: var(--text-xs);
letter-spacing: 0.18em; text-transform: uppercase;
color: var(--bg-soft);
display:flex; align-items:center; gap: 8px;
padding: 6px 12px;
border: 1px solid rgba(241,243,246,0.4);
backdrop-filter: blur(4px);
background: rgba(12,15,20,0.28);
cursor: pointer;
transition: background .3s, border-color .3s, gap .3s;
}
.enter-cue:hover{ background: rgba(12,15,20,0.55); border-color: var(--bg-soft); gap: 14px; }
/* meta col */
.meta-col{
display:flex; flex-direction: column; justify-content: space-between;
gap: 16px; padding: 4px 0;
}
.meta-block{ border-top: 1px solid var(--line); padding-top: 10px; }
.meta-block .value{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: 19px; letter-spacing: 0.01em; line-height: 1.35;
color: var(--ink); margin-top: 6px;
transition: opacity .35s, transform .45s cubic-bezier(.2,.8,.2,1);
}
.meta-col.swap .meta-block .value{ opacity: 0; transform: translateY(6px); }
.meta-block .label{ display:block; }
/* counter */
.foot-count{ display:flex; align-items: baseline; gap: 10px; }
.foot-count .now{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: 44px; letter-spacing: 0.01em; line-height: 1; color: var(--ink);
display:inline-block;
transition: opacity .35s, transform .5s cubic-bezier(.2,.8,.2,1);
}
.foot-count.swap .now{ opacity: 0; transform: translateY(8px); }
.foot-count .of{
font-family: var(--mono); font-size: var(--text-xs);
letter-spacing: 0.14em; color: var(--ink-soft);
}
.progress-track{
margin-top: 8px; width: 100%; height: 1px;
background: var(--line-soft); position: relative;
}
.progress-track::after{
content:""; position:absolute; left:0; top:0; height:100%;
width: var(--p, 25%);
background: var(--steel);
transition: width .6s cubic-bezier(.2,.8,.2,1);
}
.foot-cta{
display:inline-flex; align-items: center; gap: 12px;
padding: 10px 18px;
border: 1px solid var(--ink); background: var(--ink); color: var(--bg-soft);
font-family: var(--mono); font-size: var(--text-xs);
letter-spacing: 0.18em; text-transform: uppercase;
transition: background .4s, border-color .4s, gap .4s;
}
.foot-cta:hover{ background: var(--steel); border-color: var(--steel); gap: 22px; }
.foot-right-stack{ display:flex; flex-direction: column; align-items:flex-end; gap: 4px; }
.foot-right-stack a{ border-bottom: 1px solid transparent; transition: border-color .3s; }
.foot-right-stack a:hover{ border-color: var(--ink-soft); }
/* intro splash */
.intro{
position: fixed; inset: 0; z-index: 80;
background: var(--bg);
display: flex; align-items: center; justify-content: center;
transition: opacity .9s ease, visibility .9s;
}
.intro.hide{ opacity: 0; visibility: hidden; }
.intro-content{ max-width: 760px; padding: 0 32px; text-align: center; }
.intro-tag{
font-family: var(--mono); font-size: var(--text-xs);
letter-spacing: 0.3em; text-transform: uppercase;
color: var(--ink-soft);
display:inline-flex; align-items: center; gap: 14px;
}
.intro-tag .ln{ width: 28px; height: 1px; background: var(--line); }
.intro-title{
margin-top: 32px;
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: clamp(64px, 9vw, 128px); line-height: 1;
letter-spacing: 0.005em;
}
.intro-title .accent{ color: var(--steel); }
.intro-sub{
margin-top: 24px;
font-family: var(--mono); font-size: 11px;
letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-soft);
}
.intro-quote{
margin: 40px auto 0; max-width: 540px;
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: 19px; line-height: 1.5; color: var(--ink);
letter-spacing: 0.01em;
}
.intro-quote::before{ content:"〝 "; color: var(--steel); }
.intro-quote::after{ content:" 〞"; color: var(--steel); }
.intro-attr{
margin-top: 14px; font-family: var(--mono); font-size: 10.5px;
letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-soft);
}
.intro-cta{
margin-top: 56px;
display:inline-flex; align-items: center; gap: 14px;
padding: 14px 26px;
border: 1px solid var(--ink); background: transparent; color: var(--ink);
font-family: var(--mono); font-size: 11px;
letter-spacing: 0.22em; text-transform: uppercase;
cursor: pointer;
transition: background .4s, color .4s, gap .4s;
}
.intro-cta:hover{ background: var(--ink); color: var(--bg-soft); gap: 22px; }
/* detail page */
.detail{
position: fixed; inset: 0; z-index: 60;
background: var(--bg);
overflow-y: auto;
visibility: hidden; opacity: 0;
transition: opacity .55s ease, visibility .55s;
}
.detail.open{ visibility: visible; opacity: 1; }
.detail-bar{
position: sticky; top: 0; z-index: 5;
display:flex; justify-content: space-between; align-items: center;
padding: 14px 26px;
border-bottom: 1px solid var(--line);
background: rgba(230,233,238,0.92);
backdrop-filter: blur(8px);
}
.detail-bar .crumbs{
font-family: var(--mono); font-size: 10.5px;
letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-soft);
}
.detail-bar .crumbs .sep{ margin: 0 10px; color: var(--line); }
.detail-bar .crumbs .cur{ color: var(--ink); }
.detail-close{
display:inline-flex; align-items: center; gap: 10px;
padding: 8px 14px; border: 1px solid var(--line);
font-family: var(--mono); font-size: 10.5px;
letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink);
transition: background .35s, color .35s, border-color .35s;
}
.detail-close:hover{ background: var(--ink); color: var(--bg-soft); border-color: var(--ink); }
.detail-inner{ max-width: 1320px; margin: 0 auto; padding: 80px 26px 120px; }
.detail-hero{
display: grid; grid-template-columns: 1fr 360px;
gap: 56px; align-items: end;
}
.detail-tag-row{ display:flex; align-items: center; gap: 16px; margin-bottom: 22px; }
.detail-tag-row .steel-pill{
display:inline-flex; align-items: center; gap: 8px;
padding: 5px 12px;
background: var(--steel); color: var(--bg-soft);
font-family: var(--mono); font-size: 10px;
letter-spacing: 0.2em; text-transform: uppercase;
}
.detail-tag-row .ln{ flex: 1; height: 1px; background: var(--line); }
.detail-title{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: clamp(72px, 9vw, 140px); line-height: 0.95; letter-spacing: 0.005em; color: var(--ink);
}
.detail-title .accent-line{
display: inline-block;
border-bottom: 1px solid var(--steel);
padding-bottom: 6px;
}
.detail-subtitle{
margin-top: 18px;
font-family: var(--mono); font-size: 11px;
letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink-soft);
}
.detail-spec-table{ border-top: 1px solid var(--ink); padding-top: 16px; }
.detail-spec-table dl{
display: grid; grid-template-columns: 1fr 1.4fr;
gap: 14px 12px; font-size: 13px; line-height: 1.55;
}
.detail-spec-table dt{
font-family: var(--mono); font-size: 10px;
letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-soft);
}
.detail-spec-table dd{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: 15px; letter-spacing: 0.01em; color: var(--ink); line-height: 1.4;
}
.detail-hero-img{
margin-top: 56px;
width: 100%; aspect-ratio: 21/9;
overflow: hidden; background: var(--paper); position: relative;
border: 1px solid var(--line);
}
.detail-hero-img img{ width: 100%; height: 100%; display: block; object-fit: cover; }
.detail-hero-img .caption{
position: absolute; left: 16px; bottom: 12px;
font-family: var(--mono); font-size: 10px;
letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink);
background: var(--bg-soft); padding: 4px 8px;
}
.detail-body{ margin-top: 64px; display: grid; grid-template-columns: 1fr 2fr; gap: 64px; }
.detail-body .left h4{
font-family: var(--mono); font-size: 11px;
letter-spacing: 0.18em; text-transform: uppercase;
color: var(--ink-soft); font-weight: 500;
}
.detail-body .left p{
margin-top: 10px;
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: 19px; line-height: 1.4; letter-spacing: 0.01em; color: var(--ink);
}
.detail-body .right .lead{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: 24px; line-height: 1.45; letter-spacing: 0.005em; color: var(--ink);
border-left: 1px solid var(--steel); padding-left: 24px;
}
.detail-body .right .lead em{
font-style: italic; border-bottom: 1px solid var(--steel); padding-bottom: 1px;
}
.detail-body .right .prose{ margin-top: 36px; max-width: 620px; }
.detail-body .right .prose p{ margin-top: 18px; font-size: 14.5px; line-height: 1.85; color: #1a1d22; }
.detail-body .right .prose p:first-child{ margin-top: 0; }
.detail-gallery{ margin-top: 96px; border-top: 1px solid var(--line); padding-top: 32px; }
.detail-gallery-head{
display:flex; justify-content: space-between; align-items: baseline; margin-bottom: 24px;
}
.detail-gallery-head h3{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: 32px; letter-spacing: 0.01em;
}
.detail-gallery-head .count{
font-family: var(--mono); font-size: 10.5px;
letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-soft);
}
.grid-figs{ display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; }
.grid-figs figure{
margin: 0; background: var(--paper); overflow: hidden; position: relative;
border: 1px solid var(--line);
}
.grid-figs figure img{
width: 100%; height: 100%; display: block; object-fit: cover;
transition: transform 1.4s cubic-bezier(.2,.8,.2,1);
}
.grid-figs figure:hover img{ transform: scale(1.04); }
.grid-figs figure figcaption{
position: absolute; left: 8px; bottom: 8px;
font-family: var(--mono); font-size: 9.5px;
letter-spacing: 0.16em; text-transform: uppercase;
color: var(--ink); background: var(--bg-soft); padding: 3px 7px;
}
.grid-figs .f-3{ grid-column: span 3; aspect-ratio: 16/10; }
.grid-figs .f-2{ grid-column: span 2; aspect-ratio: 4/5; }
.grid-figs .f-4{ grid-column: span 4; aspect-ratio: 16/9; }
.grid-figs .f-6{ grid-column: span 6; aspect-ratio: 21/8; }
.detail-nav{
margin-top: 96px; border-top: 1px solid var(--line); padding-top: 32px;
display:flex; justify-content: space-between; gap: 24px;
}
.detail-nav a{ display:flex; flex-direction: column; gap: 6px; transition: opacity .35s; }
.detail-nav a:hover{ opacity: 0.55; }
.detail-nav a .nav-lbl{
font-family: var(--mono); font-size: 10px;
letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-soft);
}
.detail-nav a .nav-title{
font-family: var(--serif); font-style: italic; font-weight: 400;
font-size: 26px; letter-spacing: 0.01em; color: var(--ink);
}
.detail-nav .next{ text-align: right; align-items: flex-end; }
.cursor-dot{
position: fixed; top:0; left: 0;
width: 7px; height: 7px; border-radius: 50%;
background: var(--steel);
pointer-events: none; z-index: 200;
transform: translate(-50%,-50%);
transition: width .25s, height .25s, opacity .25s;
mix-blend-mode: difference;
opacity: 0.9;
}
body.cursor-large .cursor-dot{ width: 38px; height: 38px; opacity: .9; }
@media (max-width: 1100px){
.stage{ grid-template-columns: 220px 1fr 220px; }
}
@media (max-width: 820px){
.stage{
grid-template-columns: 1fr;
grid-template-rows: 60px 280px 320px auto 60px;
}
.g1c1, .g1c3{ display: none; }
.g1c2{ grid-column: 1; grid-row: 1; }
.g2c1{ grid-column: 1; grid-row: 4; }
.g2c2{ grid-column: 1; grid-row: 2; }
.g2c3{ grid-column: 1; grid-row: 3; flex-direction: row; flex-wrap: wrap; gap: 8px; }
.g3c1, .g3c3{ display: none; }
.g3c2{ grid-column: 1; grid-row: 5; }
.detail-hero{ grid-template-columns: 1fr; gap: 24px; }
.detail-body{ grid-template-columns: 1fr; gap: 24px; }
.grid-figs{ grid-template-columns: repeat(2, 1fr); }
.grid-figs .f-3, .grid-figs .f-2, .grid-figs .f-4, .grid-figs .f-6{ grid-column: span 2; aspect-ratio: 4/3; }
}
</style>
</head>
<body>
<div class="ticker" aria-hidden="true">
<div class="ticker-track" id="ticker-track"></div>
</div>
<!-- ===================== intro splash ===================== -->
<div class="intro" id="intro">
<div class="intro-content">
<div class="intro-tag">
<span class="ln"></span>
<!-- SWAP-2 顶标 vol -->
<span>{{INTRO_VOLUME_TAG}}</span>
<span class="ln"></span>
</div>
<!-- SWAP-3 主标题(中文,带分隔点 . 用 .accent 包裹);句中点必须用 <span class="accent">·</span> -->
<h1 class="intro-title">{{INTRO_TITLE_HTML}}</h1>
<!-- SWAP-4 主副标 英文 -->
<p class="intro-sub">{{INTRO_TITLE_EN}}</p>
<!-- SWAP-5 引言(一句策展话) -->
<p class="intro-quote">{{INTRO_QUOTE}}</p>
<!-- SWAP-6 引言出处 -->
<p class="intro-attr">{{INTRO_ATTRIBUTION}}</p>
<button class="intro-cta" id="intro-enter">
<span>进入展厅</span>
<span>—→</span>
</button>
</div>
</div>
<!-- ===================== main stage ===================== -->
<main class="stage">
<div class="g1c1 head-mark">
<!-- SWAP-7 logo 字符(建议 1-2 个 serif 字符,如 Æ / 沉 / Ø) -->
<span class="glyph">{{HEAD_GLYPH}}</span>
<!-- SWAP-8 logo 下方说明 -->
<span class="label">{{HEAD_GLYPH_SUB}}</span>
</div>
<div class="g1c2 head-center">
<!-- SWAP-9 中央顶标·中文 -->
<span class="label label--ink">{{HEAD_CENTER_CN}}</span>
<!-- SWAP-10 中央顶标·英文 -->
<span class="label">{{HEAD_CENTER_EN}}</span>
</div>
<div class="g1c3 head-right">
<!-- SWAP-11 顶右上行(日期 / 城市) -->
<span class="label">{{HEAD_RIGHT_TOP}}</span>
<!-- SWAP-12 顶右下行(坐标 / 编号) -->
<span class="label">{{HEAD_RIGHT_BOTTOM}}</span>
</div>
<aside class="g2c1 sidebar">
<div class="sidebar-head">
<span class="label">展品目录</span>
<div class="rule"></div>
<span class="label" style="margin-top:8px;display:block">SELECTED OBJECTS</span>
</div>
<ol class="proj-list" id="proj-list">
<div class="indicator" id="indicator"></div>
<!-- proj-list 由 JS 根据 EXHIBITS 数据自动生成,不要手动写 li -->
</ol>
</aside>
<section class="g2c2 viewport" id="viewport">
<span class="bracket bracket--tl"></span>
<span class="bracket bracket--tr"></span>
<span class="bracket bracket--bl"></span>
<span class="bracket bracket--br"></span>
<button class="enter-cue" id="enter-cue">
<span>查看展品</span>
<span>→</span>
</button>
<div class="viewport-mask">
<div class="cover-stage" id="cover-stage">
<div class="cover-flipper" id="cover-flipper">
<div class="cover-face front" id="face-front"></div>
<div class="cover-face back" id="face-back"></div>
</div>
</div>
</div>
<div class="cover-caption" id="cover-caption">
<div class="lhs">
<div class="ch" id="cover-ch"></div>
<div class="en" id="cover-en"></div>
</div>
<div class="rhs" id="cover-rhs"></div>
</div>
</section>
<aside class="g2c3 meta-col" id="meta-col">
<div class="meta-block">
<span class="label">材料 / MATERIAL</span>
<div class="value" id="meta-material"></div>
</div>
<div class="meta-block">
<span class="label">产地 / ORIGIN</span>
<div class="value" id="meta-origin"></div>
</div>
<div class="meta-block">
<span class="label">年代 / YEAR</span>
<div class="value" id="meta-year"></div>
</div>
<div class="meta-block">
<span class="label">尺寸 / DIMENSION</span>
<div class="value" id="meta-dim"></div>
</div>
</aside>
<div class="g3c1">
<div class="foot-count" id="foot-count">
<span class="now" id="cur-num">01</span>
<span class="of" id="cur-total">/ 04 件展品</span>
</div>
<div class="progress-track" id="progress-track" style="--p:25%"></div>
</div>
<div class="g3c2" style="display:flex; justify-content:space-between; align-items:flex-end; gap:24px;">
<button class="nav-btn" id="btn-prev">
<span class="arr">←</span><span class="txt">上一件</span>
</button>
<button class="foot-cta" id="btn-enter">
<span>进入展品 / VIEW EXHIBIT</span><span>—→</span>
</button>
<button class="nav-btn" id="btn-next">
<span class="txt">下一件</span><span class="arr">→</span>
</button>
</div>
<div class="g3c3 foot-right-stack">
<span class="label">策展 / CURATED BY</span>
<!-- SWAP-13 策展方名称 -->
<span class="serif-tag">{{CURATOR_NAME}}</span>
<!-- SWAP-14 联系邮箱 -->
<span class="label" style="margin-top:6px"><a href="mailto:{{CURATOR_EMAIL}}">{{CURATOR_EMAIL}}</a></span>
</div>
</main>
<div class="cursor-dot" id="cursor"></div>
<!-- detail page -->
<div class="detail" id="detail" aria-hidden="true">
<div class="detail-bar">
<div class="crumbs">
<!-- SWAP-15 面包屑两个固定项 -->
<span>{{CRUMB_ROOT}}</span><span class="sep">/</span>
<span>{{CRUMB_VOLUME}}</span><span class="sep">/</span>
<span class="cur" id="crumb-cur"></span>
</div>
<button class="detail-close" id="detail-close">
<span>←</span><span>返回展厅</span>
</button>
</div>
<div class="detail-inner" id="detail-inner"></div>
</div>
<!-- ===================== exhibit data =====================
SWAP-16 ★最关键★ — 这里放 N 个展品对象。可 1—N 件,建议 3—6 件最好看。
每个对象字段:
slug URL 用 ID(小写英文连字符),如 "iron-kettle"
num 编号,如 "N°01"
title 中文名,2-4 字最好看
en 英文名,全大写
year 年份字符串
place "国家 · 城市"
maker "工厂英文名 / 工厂中文名"
material 材料,可用 <br/> 分行
origin 产地段落,可用 <br/>
dim 尺寸,可用 <br/>
tagline 英文短语 + / + 中文短语,如 "FORGED ESSENTIAL / 锻造的必需品"
lead 一句开篇话,可用 <em>关键词</em> 强调一处
spec 数组 [[键, 值], ...],4-6 行
prose 数组 [段一, 段二, 段三],每段 80-160 字
cover 主图 URL(必填)— 实物照片 / 工业蓝图 / 渲染图都行
gallery 数组 [{src, layout, caption}, ...],3-6 张图
layout 必填,三选一:f-3 (16:10) / f-2 (4:5) / f-4 (16:9) / f-6 (21:8)
caption 是图片角标短语
-->
<script id="exhibits-data" type="application/json">
[
{
"slug": "example-one",
"num": "N°01",
"title": "示例物",
"en": "EXAMPLE OBJECT",
"year": "1958",
"place": "丹麦 · 哥本哈根",
"maker": "Example Værksted / 示例工坊",
"material": "灰口铸铁 · 山毛榉木柄<br/>黑釉烧结涂层",
"origin": "丹麦 · 哥本哈根<br/>Example Værksted",
"dim": "高 18 cm · 容 1.2 L<br/>底径 14 cm",
"tagline": "FORGED ESSENTIAL / 锻造的必需品",
"lead": "一只为<em>沉默与水的低温</em>而设计的容器——它的厚度,是为了让热停留得久一点。",
"spec": [
["编号", "1958 · K-04"],
["材料", "灰口铸铁 + 山毛榉"],
["工艺", "砂模铸造 / 黑釉烧结"],
["重量", "1.86 kg"],
["产量", "限量 240 件 · 现存 31"]
],
"prose": [
"示例段落一:来源 / 语境。介绍这件物件的产地、年份、出身工坊,以及它被造出来的那个时代背景。80-160 字。",
"示例段落二:工艺 / 决定。这件物件最关键的设计决定是什么?哪些细节是手工的、哪些是机器的?为什么要这么做?",
"示例段落三:呈现 / 影响。它今天还在被使用吗?现存几件?它对后来的同类物件留下了什么?"
],
"cover": "https://picsum.photos/seed/industrial-example-1/1600/1000?grayscale",
"gallery": [
{"src": "https://picsum.photos/seed/industrial-example-1a/900/600?grayscale", "layout": "f-3", "caption": "正立面"},
{"src": "https://picsum.photos/seed/industrial-example-1b/600/750?grayscale", "layout": "f-2", "caption": "把手细部"},
{"src": "https://picsum.photos/seed/industrial-example-1c/600/750?grayscale", "layout": "f-2", "caption": "嘴部细节"},
{"src": "https://picsum.photos/seed/industrial-example-1d/600/750?grayscale", "layout": "f-2", "caption": "底部印记"},
{"src": "https://picsum.photos/seed/industrial-example-1e/1200/675?grayscale", "layout": "f-4", "caption": "工坊蓝图"},
{"src": "https://picsum.photos/seed/industrial-example-1f/1600/610?grayscale", "layout": "f-6", "caption": "陈列剖面"}
]
},
{
"slug": "example-two",
"num": "N°02",
"title": "示例器",
"en": "EXAMPLE INSTRUMENT",
"year": "1974",
"place": "芬兰 · 坦佩雷",
"maker": "Example Lab / 示例研究所",
"material": "黄铜外圈 · 钢制滚珠轴承<br/>磨砂硼硅玻璃罩",
"origin": "芬兰 · 坦佩雷<br/>Example Lab",
"dim": "悬挑 42 cm · 罩径 Ø 22 cm<br/>整重 1.4 kg",
"tagline": "PIVOT & LIGHT / 旋转与光",
"lead": "把<em>工业用的滚珠轴承</em>,从机器的内部,请到一件家用器物的关节里。",
"spec": [
["编号", "1974 · A-Lab 17"],
["关节", "SKF 6204 滚珠轴承"],
["罩体", "磨砂硼硅玻璃"],
["产量", "实验批次 80 件"]
],
"prose": [
"示例段落一:废料计划的开端、回收的轴承编号、为什么是它。80-160 字。",
"示例段落二:装配的精度、可旋转的角度、停在任何角度的可能。",
"示例段落三:目录里那句冷静的话——废品的精度,仍是合格的精度。"
],
"cover": "https://picsum.photos/seed/industrial-example-2/1600/1000?grayscale",
"gallery": [
{"src": "https://picsum.photos/seed/industrial-example-2a/900/600?grayscale", "layout": "f-3", "caption": "悬挑全貌"},
{"src": "https://picsum.photos/seed/industrial-example-2b/600/750?grayscale", "layout": "f-2", "caption": "黄铜外圈"},
{"src": "https://picsum.photos/seed/industrial-example-2c/600/750?grayscale", "layout": "f-2", "caption": "轴承剖面"},
{"src": "https://picsum.photos/seed/industrial-example-2d/600/750?grayscale", "layout": "f-2", "caption": "罩体正视"},
{"src": "https://picsum.photos/seed/industrial-example-2e/1200/675?grayscale", "layout": "f-4", "caption": "节点装配图"},
{"src": "https://picsum.photos/seed/industrial-example-2f/1600/610?grayscale", "layout": "f-6", "caption": "研究所现场"}
]
}
]
</script>
<!-- ===================== ticker text =====================
SWAP-17 跑马灯尾巴的固定串。前半截会自动用所有展品拼出来,这里只写最后那段总标语。
-->
<script id="ticker-tail" type="text/plain">— {{TICKER_TAIL}}</script>
<script>
/* ============================================================
App logic — 不要改 JS / 不要改 CSS
============================================================ */
const DATA = JSON.parse(document.getElementById('exhibits-data').textContent);
const TICKER_TAIL = document.getElementById('ticker-tail').textContent.trim();
let cur = 0;
let switching = false;
const coverStage = document.getElementById('cover-stage');
const coverCap = document.getElementById('cover-caption');
const coverCh = document.getElementById('cover-ch');
const coverEn = document.getElementById('cover-en');
const coverRhs = document.getElementById('cover-rhs');
const projList = document.getElementById('proj-list');
const indicator = document.getElementById('indicator');
const metaCol = document.getElementById('meta-col');
const metaMat = document.getElementById('meta-material');
const metaOrigin = document.getElementById('meta-origin');
const metaYear = document.getElementById('meta-year');
const metaDim = document.getElementById('meta-dim');
const footCount = document.getElementById('foot-count');
const curNum = document.getElementById('cur-num');
const curTotal = document.getElementById('cur-total');
const progress = document.getElementById('progress-track');
const btnPrev = document.getElementById('btn-prev');
const btnNext = document.getElementById('btn-next');
const btnEnter = document.getElementById('btn-enter');
const enterCue = document.getElementById('enter-cue');
const detail = document.getElementById('detail');
const detailInner = document.getElementById('detail-inner');
const detailClose = document.getElementById('detail-close');
const crumbCur = document.getElementById('crumb-cur');
const introEl = document.getElementById('intro');
const introEnter = document.getElementById('intro-enter');
const cursor = document.getElementById('cursor');
const tickerTrack = document.getElementById('ticker-track');
const flipper = document.getElementById('cover-flipper');
const faceFront = document.getElementById('face-front');
const faceBack = document.getElementById('face-back');
let flipperRotation = 0;
let currentFace = 'front';
/* —— 通用图片封面 ——
用 <img> 替代 reference.html 里的 SVG。frame-marks 在 4 个角加工程图角注,
保留 reference.html 那种"画框 / 蓝图编号"的工业语境。 */
function coverHTML(d){
const safe = (s) => (s || '').replace(/"/g, '&quot;');
return `
<img src="${safe(d.cover)}" alt="${safe(d.title)}" onerror="this.style.opacity=0.3"/>
<div class="frame-marks" aria-hidden="true">
<span class="ft">${safe(d.num)} / ${safe(d.en)} / ${safe(d.year)}</span>
<span class="fr">SCALE 1:1</span>
<span class="fl">${safe((d.maker||'').split('/')[0].trim())}</span>
<span class="fb">CAT. ${safe((d.num||'').replace(/\D/g,''))} / ${safe(d.year)}</span>
</div>
`;
}
/* —— 总件数文案 + 进度条 —— */
curTotal.textContent = `/ ${String(DATA.length).padStart(2,'0')} 件展品`;
/* —— 自动生成 sidebar 项目列表 —— */
function renderSidebar(){
const items = DATA.map((d, i) => {
const ttl = d.title.split('').map((c, j) =>
`<span class="ch" style="--i:${j}">${c}</span>`
).join('');
return `<li data-idx="${i}"${i===0?' class="active"':''}>
<span class="num">${d.num}</span>
<span class="ttl">${ttl}</span>
</li>`;
}).join('');
// 保留 indicator div,不要覆盖
const indicatorHTML = '<div class="indicator" id="indicator"></div>';
projList.innerHTML = indicatorHTML + items;
}
renderSidebar();
// 重新拿 indicator 引用(renderSidebar 重写过 innerHTML)
const indicatorEl = document.getElementById('indicator');
/* —— 初始正面 —— */
faceFront.innerHTML = coverHTML(DATA[0]);
/* —— ticker —— */
const tickerText = DATA.map(d =>
`<span><span class="dot">●</span> ${d.num} · ${d.title} / ${d.en} · ${d.year} · ${d.place}</span>`
).join('') + `<span>${TICKER_TAIL}</span>`;
tickerTrack.innerHTML = tickerText + tickerText;
function applyMeta(d, idx){
coverCap.classList.add('swap');
metaCol.classList.add('swap');
footCount.classList.add('swap');
setTimeout(() => {
coverCh.textContent = d.title;
coverEn.textContent = `${d.num} · ${d.en} · ${d.year}`;
coverRhs.innerHTML = `编号 / ${d.num}<br/>${d.place}<br/>${d.maker.split('/')[0].trim()}`;
metaMat.innerHTML = d.material;
metaOrigin.innerHTML = d.origin;
metaYear.innerHTML = d.year;
metaDim.innerHTML = d.dim;
curNum.textContent = String(idx + 1).padStart(2, '0');
progress.style.setProperty('--p', `${((idx + 1) / DATA.length) * 100}%`);
coverCap.classList.remove('swap');
metaCol.classList.remove('swap');
footCount.classList.remove('swap');
}, 280);
[...projList.querySelectorAll('li')].forEach((li, i) => li.classList.toggle('active', i === idx));
const activeLi = projList.querySelector('li.active');
if(activeLi){
const r1 = projList.getBoundingClientRect();
const r2 = activeLi.getBoundingClientRect();
indicatorEl.style.top = (r2.top - r1.top + 8) + 'px';
indicatorEl.style.height = (r2.height - 16) + 'px';
}
}
function setCover(idx, direction='next'){
const incomingFace = currentFace === 'front' ? faceBack : faceFront;
incomingFace.innerHTML = coverHTML(DATA[idx]);
flipperRotation += (direction === 'next' ? -180 : 180);
flipper.style.transform = `translateZ(0) rotateY(${flipperRotation}deg)`;
currentFace = currentFace === 'front' ? 'back' : 'front';
}
function go(idx){
if(switching) return;
const target = (idx + DATA.length) % DATA.length;
if(target === cur) return;
const direction =
(target - cur + DATA.length) % DATA.length === 1 ? 'next' :
(cur - target + DATA.length) % DATA.length === 1 ? 'prev' :
(target > cur ? 'next' : 'prev');
switching = true;
cur = target;
const vp = document.getElementById('viewport');
vp.classList.add('switching');
setCover(target, direction);
applyMeta(DATA[target], target);
setTimeout(() => {
switching = false;
vp.classList.remove('switching');
}, 1050);
}
applyMeta(DATA[0], 0);
window.addEventListener('load', () => applyMeta(DATA[cur], cur));
window.addEventListener('resize', () => applyMeta(DATA[cur], cur));
btnPrev.addEventListener('click', () => go(cur - 1));
btnNext.addEventListener('click', () => go(cur + 1));
projList.addEventListener('click', e => {
const li = e.target.closest('li[data-idx]');
if(li) go(Number(li.dataset.idx));
});
function hideIntro(){
introEl.classList.add('hide');
setTimeout(() => { introEl.style.display = 'none'; }, 950);
}
introEnter.addEventListener('click', hideIntro);
setTimeout(() => { if(!introEl.classList.contains('hide')) hideIntro(); }, 7000);
function renderDetail(idx){
const d = DATA[idx];
const prev = DATA[(idx - 1 + DATA.length) % DATA.length];
const next = DATA[(idx + 1) % DATA.length];
crumbCur.textContent = `${d.num} · ${d.title}`;
const specRows = d.spec.map(([k,v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('');
const figs = d.gallery.map(g => `
<figure class="${g.layout}">
<img src="${g.src}" alt="${g.caption||''}" onerror="this.style.opacity=0.3"/>
${g.caption ? `<figcaption>${g.caption}</figcaption>` : ''}
</figure>`).join('');
detailInner.innerHTML = `
<section class="detail-hero">
<div class="detail-title-block">
<div class="detail-tag-row">
<span class="steel-pill">${d.num} · ${d.tagline}</span>
<span class="ln"></span>
</div>
<h2 class="detail-title">${d.title.split('').map(c=>`<span class="accent-line">${c}</span>`).join('')}</h2>
<p class="detail-subtitle">${d.en} · ${d.year} · ${d.place}</p>
</div>
<div class="detail-spec-table"><dl>${specRows}</dl></div>
</section>
<div class="detail-hero-img">
<img src="${d.cover}" alt="${d.title}" onerror="this.style.opacity=0.3"/>
<span class="caption">${d.num} · 主图 / Cover plate</span>
</div>
<section class="detail-body">
<div class="left">
<h4>制作者 / MAKER</h4>
<p>${d.maker}</p>
<h4 style="margin-top:32px">藏品序号 / CAT.</h4>
<p>${d.year} · ${d.num.replace('N°','')}</p>
<h4 style="margin-top:32px">现存 / EXTANT</h4>
<p>${d.spec[d.spec.length - 1][1]}</p>
</div>
<div class="right">
<div class="lead">${d.lead}</div>
<div class="prose">${d.prose.map(p => `<p>${p}</p>`).join('')}</div>
</div>
</section>
<section class="detail-gallery">
<div class="detail-gallery-head">
<h3>陈列细节 / Gallery</h3>
<span class="count">${d.gallery.length} 帧 · ${d.num}</span>
</div>
<div class="grid-figs">${figs}</div>
</section>
<nav class="detail-nav">
<a data-go="${DATA.indexOf(prev)}" href="#">
<span class="nav-lbl">← 上一件 / PREV</span>
<span class="nav-title">${prev.num} · ${prev.title}</span>
</a>
<a class="next" data-go="${DATA.indexOf(next)}" href="#">
<span class="nav-lbl">下一件 / NEXT →</span>
<span class="nav-title">${next.num} · ${next.title}</span>
</a>
</nav>
`;
detailInner.querySelectorAll('.detail-nav a').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
const ni = Number(a.dataset.go);
const dir = ni > cur ? 'next' : 'prev';
cur = ni;
applyMeta(DATA[ni], ni);
setCover(ni, dir);
renderDetail(ni);
detail.scrollTop = 0;
});
});
}
function openDetail(){
renderDetail(cur);
detail.classList.add('open');
document.body.classList.add('detail-open');
}
function closeDetail(){
detail.classList.remove('open');
document.body.classList.remove('detail-open');
}
btnEnter.addEventListener('click', openDetail);
enterCue.addEventListener('click', e => { e.stopPropagation(); openDetail(); });
detailClose.addEventListener('click', closeDetail);
document.getElementById('viewport').addEventListener('click', e => {
if(e.target.closest('.enter-cue')) return;
if(e.target.closest('.bracket')) return;
openDetail();
});
window.addEventListener('keydown', e => {
if(detail.classList.contains('open')){
if(e.key === 'Escape') closeDetail();
return;
}
if(!introEl.classList.contains('hide')){
if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); hideIntro(); }
return;
}
if(e.key === 'ArrowLeft') go(cur - 1);
if(e.key === 'ArrowRight') go(cur + 1);
if(e.key === 'Enter') openDetail();
});
let mx=0, my=0, cx=0, cy=0;
window.addEventListener('mousemove', e => { mx = e.clientX; my = e.clientY; });
(function loop(){
cx += (mx - cx) * 0.22;
cy += (my - cy) * 0.22;
cursor.style.transform = `translate(${cx}px, ${cy}px) translate(-50%, -50%)`;
requestAnimationFrame(loop);
})();
['.proj-list li','.nav-btn','.foot-cta','.intro-cta','.detail-close','.enter-cue','.viewport','.detail-nav a']
.forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
el.addEventListener('mouseenter', () => document.body.classList.add('cursor-large'));
el.addEventListener('mouseleave', () => document.body.classList.remove('cursor-large'));
});
});
const viewportEl = document.getElementById('viewport');
let tiltTargetX = 0, tiltTargetY = 0, tiltX = 0, tiltY = 0;
let tiltActive = false;
viewportEl.addEventListener('mousemove', e => {
const r = viewportEl.getBoundingClientRect();
tiltTargetY = ((e.clientX - r.left) / r.width - 0.5);
tiltTargetX = -((e.clientY - r.top) / r.height - 0.5);
tiltActive = true;
});
viewportEl.addEventListener('mouseleave', () => {
tiltTargetX = 0; tiltTargetY = 0;
setTimeout(() => { tiltActive = false; }, 800);
});
(function tiltLoop(){
tiltX += (tiltTargetX - tiltX) * 0.06;
tiltY += (tiltTargetY - tiltY) * 0.06;
if(tiltActive || Math.abs(tiltX) > 0.005 || Math.abs(tiltY) > 0.005){
coverStage.style.perspectiveOrigin = `${50 + tiltY * 8}% ${50 + tiltX * 5}%`;
}
requestAnimationFrame(tiltLoop);
})();
let autoTimer = setInterval(() => {
if(detail.classList.contains('open')) return;
if(!introEl.classList.contains('hide')) return;
if(DATA.length <= 1) return;
go(cur + 1);
}, 9000);
['mousedown','keydown','touchstart'].forEach(ev =>
window.addEventListener(ev, () => { clearInterval(autoTimer); autoTimer = null; }, { once: true })
);
</script>
</body>
</html>