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
This commit is contained in:
Z User committed 2026-10-27 22:50:50 +00:00
1 parent 7acc0c5451
commit e45b68bb8b
1089 files changed
+260614

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
---
name: digital-eguide
description: Premium "digital e-guide" template family — two patterns sharing a metadata-rich, editorial-archive identity: an infinite 3D-canvas portfolio (case/) for designer 作品集, and a 12-slide horizontal field-notes carousel (social-carousel.html) for long-form knowledge series.
visibility: public
mode: template
carrier: web-page
scenario: portfolio-and-guide
pattern_source: case(无限画布:作品集)/case/index.html, social-carousel.html
source_priority: skill-first
triggers:
- "做个 e-guide 电子手册"
- "3D 画布作品集"
- "field notes 横滚"
- "知识系列横滚"
- "高端作品集"
- "无限画布作品集"
---
# Digital E-Guide Template
Use this template family for **premium, metadata-rich, exploratory digital artifacts** — designer portfolios, archival photo series, knowledge guides — that reward slow browsing instead of fast scrolling.
This template ships two variants. Pick before building.
## Variants
### `case/` — Infinite-canvas portfolio (作品集)
> On-disk path: `case(无限画布:作品集)/case/index.html`
- A Miro-like 3D-cylindrical canvas of 18 tiles you drag, zoom, and click to open case detail pages.
- Use for: designer 作品集, studio portfolio, photographer archive, case-study lobby.
- Distinctive moves: Three.js-style cylindrical projection, drag inertia, gold/dark glass UI, hash-routed detail pages, "Works / About / Resume" nav.
- Companion generator: see top-level `../vr-canvas/` template (modern frontmatter version).
### `social-carousel.html` — 12-frame field-notes carousel
- A 50/50 photo + metadata carousel with 12 horizontal slides, progress bar, slide counter, scaling-on-focus serif title.
- Use for: digital ebook chapter navigation, photography series, "12 lessons from the field"-style guides, museum/archive viewer.
- Distinctive moves: Fraunces italic display that scales on active slide, Inter metadata, film-frame photo marks, 50/50 desktop split that stacks on mobile.
## Use When
- The user wants a **considered, curatorial** browsing experience — not a marketing site, not a social card.
- Every item carries metadata (year, location, technique, brand, tags) and that metadata is part of the design.
- The audience is designers, creators, or curious readers who will spend minutes, not seconds.
## Avoid When
- The artifact is a fast-conversion marketing page (use `saas-landing/`).
- The content has no per-item metadata to display (the template will feel empty).
- The user actually wants a flat blog index, dashboard, or social-card pack.
- Mobile is the dominant channel and rich interaction would degrade — both variants are desktop-leaning.
## Required Reading
Read this `SKILL.md` first. Do **not** read `case/index.html` or `social-carousel.html` by default.
Read the chosen variant only when you need concrete interaction geometry — drag inertia easing, cylinder radius, slide-scale-on-focus curve, breakpoint behavior. Extract patterns; do not lift Three.js code, placeholder project names, or photo URLs wholesale.
## Design Strategy
Pick the variant from the **interaction contract**, not the topic:
- "Let me wander a room of work" → **case/**
- "Walk me through 8–14 numbered entries" → **social-carousel.html**
Then commit to the metadata schema up front (year / category / location / brand …). The template only sings when every entry carries the same set of labels.
## Layout Bias
Prefer:
- one dominant image per entry, full-bleed inside its frame
- metadata in mono / small-caps / Latin to contrast the serif display
- a single warm accent (gold, rust) over a dark or paper ground
- smooth eased motion (cubic-bezier), no bouncy springs
- generous negative space — the artifact's premium feel comes from restraint
Avoid:
- stacking multiple thumbnails per slide / tile (kills the curatorial feel)
- bright neon accents, gradient meshes, sticker decorations
- pop-in animations on every element — choose one motion idiom and hold it
- treating metadata as a footnote — surface it equally with the image
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,909 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>FIELD NOTES — Desert Archive · Vol. 01</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=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;1,9..144,400;1,9..144,500&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #ffffff;
--ink: #111111;
--ink-soft: rgba(17, 17, 17, 0.62);
--ink-mute: rgba(17, 17, 17, 0.38);
--line: rgba(17, 17, 17, 0.12);
--line-soft: rgba(17, 17, 17, 0.06);
--display: "Fraunces", "Iowan Old Style", "Apple Garamond", "Source Han Serif SC", Georgia, serif;
--sans: "Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "PingFang SC", Helvetica, Arial, sans-serif;
/* easings — Pavilion uses two */
--ease-1: cubic-bezier(0.65, 0, 0.35, 1);
--ease-2: cubic-bezier(0.4, 0, 0.2, 1);
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
/* ============ FIXED HEADER ============ */
.header {
position: fixed;
top: 22px; left: 24px; right: 24px;
display: flex; justify-content: space-between; align-items: flex-start;
z-index: 60;
pointer-events: none;
font-family: var(--sans);
font-size: 10.5px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.brand {
pointer-events: auto;
display: flex; flex-direction: column;
line-height: 1.35;
}
.brand .b-mark { font-family: var(--display); font-style: italic; font-weight: 500; font-size: 18px; letter-spacing: -0.01em; line-height: 1; margin-bottom: 6px; }
.brand .b-line { color: var(--ink-soft); }
.brand .b-line.dim { color: var(--ink-mute); }
.menu {
pointer-events: auto;
display: flex; gap: 24px; align-items: center;
}
.menu a, .menu button {
color: var(--ink);
background: none; border: 0;
text-decoration: none;
font: inherit; letter-spacing: inherit; text-transform: inherit;
cursor: pointer;
padding: 4px 0;
border-bottom: 1px solid transparent;
transition: border-color 0.2s, color 0.2s;
}
.menu a:hover, .menu button:hover { border-bottom-color: var(--ink); }
.menu .sep { width: 4px; height: 4px; border-radius: 50%; background: var(--ink); display: inline-block; }
/* ============ PAGE (Pavilion: flex row, 12px gap, 50/50) ============ */
.page {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
width: 100%;
height: 100svh;
padding: 0;
}
/* ============ STAGE (left 50%) ============ */
.stage {
width: 50%;
height: 100%;
overflow: hidden;
position: relative;
padding: 96px 0 96px 24px;
}
.stage-inner {
position: relative;
width: 100%; height: 100%;
overflow: hidden;
background: #f8f8f7;
}
.asset {
position: absolute; inset: 0;
transition: transform .75s var(--ease-1), opacity .35s var(--ease-1);
will-change: transform, opacity;
transform: translateY(100%);
opacity: 0;
}
.asset.is-active { transform: translateY(0); opacity: 1; }
.asset.is-prev { transform: translateY(-100%); opacity: 0; }
.asset.is-next { transform: translateY(100%); opacity: 0; }
.asset.is-far-prev { transform: translateY(-100%); opacity: 0; }
.asset.is-far-next { transform: translateY(100%); opacity: 0; }
.asset img {
width: 100%; height: 100%;
object-fit: cover; display: block;
filter: contrast(1.02) saturate(0.94);
}
/* film-frame marks */
.frame-marks {
position: absolute; inset: 0;
pointer-events: none;
z-index: 7;
}
.frame-marks span {
position: absolute;
width: 14px; height: 14px;
border: 1px solid rgba(255,255,255,0.55);
}
.frame-marks .tl { top: 14px; left: 14px; border-right: 0; border-bottom: 0; }
.frame-marks .tr { top: 14px; right: 14px; border-left: 0; border-bottom: 0; }
.frame-marks .bl { bottom: 14px; left: 14px; border-right: 0; border-top: 0; }
.frame-marks .br { bottom: 14px; right: 14px; border-left: 0; border-top: 0; }
.photo-caption {
position: absolute;
left: 28px; bottom: 26px;
display: flex; flex-direction: column; gap: 4px;
font-family: var(--sans);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #fff;
text-shadow: 0 0 24px rgba(0,0,0,0.5);
z-index: 8;
pointer-events: none;
}
.photo-caption .pc-row { display: flex; gap: 14px; align-items: center; }
.photo-caption .pc-row .pc-bar { width: 18px; height: 1px; background: rgba(255,255,255,0.7); }
.photo-caption .pc-val {
position: relative;
min-width: 1ch;
}
.photo-caption .pc-val span {
position: absolute; left: 0; top: 0;
opacity: 0;
transition: opacity .35s var(--ease-1);
white-space: nowrap;
}
.photo-caption .pc-val span.is-active { opacity: 1; position: relative; }
/* ============ INFO (right 50%) ============ */
.info {
width: 50%;
height: 100%;
position: relative;
padding: 96px 24px 96px 0;
}
/* facts strip — top right */
.facts {
position: absolute;
top: 96px; right: 220px;
display: flex; gap: 24px;
font-family: var(--sans);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-soft);
max-width: calc(100% - 240px);
flex-wrap: wrap;
}
.facts .fact {
display: flex; gap: 8px; align-items: baseline;
position: relative;
}
.facts .fact .k { color: var(--ink-mute); }
.facts .fact .v {
position: relative;
min-width: 4ch;
color: var(--ink);
}
.facts .fact .v span {
position: absolute; left: 0; top: 0;
opacity: 0;
transition: opacity .35s var(--ease-1);
white-space: nowrap;
}
.facts .fact .v span.is-active { opacity: 1; position: relative; }
/* project sub-info — moved to BOTTOM-LEFT to clear scaled title (中文版本会变宽,避免叠住) */
.project-info {
position: absolute;
top: auto;
bottom: 88px;
left: 0;
transform: none;
display: flex; flex-direction: column;
gap: 4px;
font-family: var(--sans);
font-size: 10.5px;
letter-spacing: 0.16em;
text-transform: uppercase;
min-height: 24px;
z-index: 2;
max-width: 42%;
}
.pi-line {
position: relative;
white-space: nowrap;
}
.pi-line span {
display: block;
position: absolute; left: 0; top: 0;
opacity: 0;
transition: opacity .35s var(--ease-1);
color: var(--ink);
}
.pi-line span.is-active { opacity: 1; position: relative; }
.pi-line.dim span { color: var(--ink-mute); }
/* titles track — Pavilion: full-height, position:relative */
.titles-track {
height: 100%;
position: relative;
}
/* dial — Pavilion: top:50%, right:0, translate(50%, -50%), half off-screen */
.dial {
--count: 16;
--radius: 96px;
width: calc(var(--radius) * 2);
height: calc(var(--radius) * 2);
position: absolute;
top: 50%; right: 0;
transform: translate(50%, -50%) rotate(var(--dial-rotation, 135deg));
transition: all .75s var(--ease-1);
pointer-events: none;
color: var(--ink);
z-index: 1;
}
.dot {
width: var(--s, 6px); height: var(--s, 6px);
opacity: var(--o, 1);
background: currentColor;
border-radius: 50%;
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%)
rotate(calc(180deg + (var(--i) * 360deg / var(--count))))
translateX(var(--radius));
transform-origin: 50%;
transition: all .35s var(--ease-1);
}
/* slide (title wheel) — Pavilion: absolute, top:50%, right:0, transform-origin:100%, padding-right:192px */
.slide {
position: absolute;
top: 50%;
right: 0;
transform-origin: 100%;
transition: transform .75s var(--ease-1), opacity .35s var(--ease-1);
display: flex;
justify-content: flex-end;
align-items: center;
padding-right: 192px;
will-change: transform, opacity;
pointer-events: none;
}
.slide.is-active { pointer-events: auto; }
.slide .title {
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 6px;
text-decoration: none;
color: var(--ink);
transition: opacity .2s var(--ease-2);
}
.slide .title:hover { opacity: 0.5; }
.slide .title > * {
transition: all .35s var(--ease-1);
text-align: right;
transform-origin: 100%;
white-space: nowrap;
line-height: 100%;
}
.slide.is-active .title > * { transition-delay: 50ms; }
.title-text {
font-family: var(--display);
font-style: italic;
font-weight: 400;
font-size: 24px;
letter-spacing: -0.01em;
}
.slide.is-active .title-text {
transform: scale(1.85);
}
.title-num {
font-family: var(--sans);
font-size: 10px;
letter-spacing: 0.12em;
color: var(--ink-mute);
height: fit-content;
overflow: hidden;
width: 0;
opacity: 0;
transform: translateY(-5px);
}
.slide.is-active .title-num { width: 30px; opacity: 1; }
/* description ribbon — Pavilion: bottom-right, single-line ticker */
.description {
position: absolute;
bottom: 24px; right: 24px;
width: calc(50% - 36px);
display: flex;
flex-direction: row;
gap: 1px;
z-index: 2;
}
.desc-text, .description > a {
background-color: var(--ink);
color: #fff;
padding: 6px 10px;
font-family: var(--sans);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
line-height: 1.5;
height: 28px;
}
.desc-text {
width: 100%;
position: relative;
overflow: hidden;
min-width: 0;
}
.desc-text p {
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0;
color: #fff;
width: calc(100% - 20px);
min-width: 0;
transition: opacity .35s var(--ease-1);
position: absolute;
left: 10px; top: 6px;
overflow: hidden;
}
.desc-text p.is-active { opacity: 1; }
.description > a {
text-decoration: none;
white-space: nowrap;
width: fit-content;
display: inline-flex; gap: 10px; align-items: center;
height: 28px;
}
.description > a span {
transition: opacity .2s var(--ease-2);
}
.description > a:hover span { opacity: 0.5; }
/* ============ FOOTER ============ */
.footer {
position: fixed;
bottom: 22px; left: 24px; right: 24px;
display: flex; justify-content: space-between; align-items: flex-end;
z-index: 60;
pointer-events: none;
font-family: var(--sans);
font-size: 10.5px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-soft);
}
.counter {
display: flex; gap: 12px; align-items: baseline;
font-feature-settings: "tnum";
}
.counter .cur {
font-family: var(--display);
font-style: italic;
font-weight: 500;
font-size: 40px;
line-height: 1;
color: var(--ink);
letter-spacing: -0.02em;
min-width: 48px; display: inline-block;
}
.counter .slash, .counter .tot { color: var(--ink-mute); padding-bottom: 3px; }
.counter .label { padding-bottom: 5px; margin-left: 8px; color: var(--ink-mute); }
.nav-group {
pointer-events: auto;
display: flex; gap: 14px; align-items: center;
}
.nav-group .hint { color: var(--ink-mute); }
.nav-group button {
background: transparent;
border: 1px solid var(--ink);
color: var(--ink);
width: 38px; height: 38px;
cursor: pointer;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
transition: background 0.25s var(--ease-1), color 0.25s var(--ease-1), transform 0.15s var(--ease-1);
font: inherit;
}
.nav-group button:hover { background: var(--ink); color: var(--bg); }
.nav-group button:active { transform: scale(0.94); }
/* progress strip — above footer right */
.progress {
position: fixed;
bottom: 76px; right: 24px;
width: 220px;
z-index: 60;
pointer-events: none;
display: flex; gap: 4px;
}
.progress span {
flex: 1; height: 1px;
background: var(--line);
position: relative;
overflow: hidden;
}
.progress span::after {
content: ""; position: absolute; inset: 0;
background: var(--ink);
transform-origin: left center;
transform: scaleX(0);
transition: transform .75s var(--ease-1);
}
.progress span.passed::after, .progress span.active::after { transform: scaleX(1); }
.kbd {
position: fixed;
left: 50%; bottom: 22px;
transform: translateX(-50%);
z-index: 60;
display: flex; gap: 6px; align-items: center;
font-family: var(--sans);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-mute);
pointer-events: none;
}
.kbd kbd {
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--line);
border-radius: 4px;
padding: 2px 6px;
font-family: inherit;
font-size: 9.5px;
color: var(--ink-soft);
background: #fff;
}
.stage { cursor: grab; }
.stage.dragging { cursor: grabbing; }
/* ============ MOBILE (Pavilion: stack column, photo 66%, info 33%) ============ */
@media (max-width: 860px) {
.page {
flex-direction: column;
gap: 0;
overflow: hidden;
touch-action: none;
overscroll-behavior: contain;
}
.stage { width: 100%; height: 60%; padding: 56px 12px 0 12px; }
.info { width: 100%; height: 40%; padding: 0 12px 60px 12px; }
.header { top: 12px; left: 14px; right: 14px; }
.footer { left: 14px; right: 14px; bottom: 14px; }
.brand .b-line { display: none; }
.menu { gap: 14px; }
.facts { display: none; }
.project-info { display: none; }
/* slides: Pavilion mobile uses transform-origin:bottom, center-aligned */
.slide {
transform-origin: bottom;
justify-content: center;
align-items: center;
padding-right: 0;
left: 50%; right: auto;
top: auto; bottom: 50%;
}
.slide .title { flex-direction: column-reverse; justify-content: center; align-items: center; }
.slide .title > * { text-align: center; transform-origin: bottom; }
.title-text { font-size: 22px; }
.slide.is-active .title-text { transform: scale(1.5); }
.dial {
--radius: 64px;
top: calc(60% - 60px);
right: 50%;
transform: translate(50%, -50%) rotate(calc(var(--dial-rotation,135deg) + 270deg));
}
.description {
bottom: 60px; right: 12px; left: 12px;
width: auto;
}
.description > a { text-align: center; width: 100%; justify-content: center; }
.progress { display: none; }
.kbd { display: none; }
.counter .cur { font-size: 28px; min-width: 36px; }
}
</style>
</head>
<body>
<header class="header">
<div class="brand">
<span class="b-mark">Field Notes</span>
<span class="b-line">Desert Archive · Vol. 01</span>
<span class="b-line dim">Est. 2023 — Ongoing</span>
</div>
<nav class="menu">
<a href="#" data-act="index">Index</a>
<a href="#" data-act="map">Map</a>
<a href="#" data-act="about">About</a>
<span class="sep" aria-hidden="true"></span>
<button type="button" data-act="subscribe">Subscribe</button>
</nav>
</header>
<main class="page">
<!-- LEFT 50%: photo stage -->
<section class="stage" id="stage" aria-label="Photo carousel">
<div class="stage-inner" id="stageInner">
<!-- assets injected by JS -->
<div class="frame-marks" aria-hidden="true">
<span class="tl"></span><span class="tr"></span>
<span class="bl"></span><span class="br"></span>
</div>
<div class="photo-caption" aria-hidden="true">
<div class="pc-row"><span class="pc-bar"></span><span id="pcFrame">FRAME 01 / 12</span></div>
<div class="pc-val" id="pcLoc"></div>
</div>
</div>
</section>
<!-- RIGHT 50%: info -->
<aside class="info">
<!-- top-right facts strip -->
<div class="facts" aria-label="Frame metadata">
<div class="fact"><span class="k">Date</span><span class="v" id="factDate"></span></div>
<div class="fact"><span class="k">Format</span><span class="v" id="factFmt"></span></div>
<div class="fact"><span class="k">Time</span><span class="v" id="factTime"></span></div>
</div>
<!-- LEFT-CENTER (per Pavilion) sub label -->
<div class="project-info">
<div class="pi-line dim" id="piNum"></div>
<div class="pi-line" id="piSub"></div>
<div class="pi-line dim" id="piLoc"></div>
</div>
<!-- RIGHT-CENTER title wheel -->
<div class="titles-track" id="titlesTrack"></div>
<!-- RIGHT EDGE dial protruding -->
<div class="dial" id="dial" aria-hidden="true"></div>
<!-- BOTTOM-RIGHT description ribbon (Pavilion) -->
<div class="description">
<div class="desc-text" id="descText">
<!-- desc <p>s injected -->
</div>
<a href="#" id="cta"><span>View Frame</span><span>→</span></a>
</div>
</aside>
</main>
<footer class="footer">
<div class="counter">
<span class="cur" id="cur">01</span>
<span class="slash">/</span>
<span class="tot" id="tot">12</span>
<span class="label">Frame</span>
</div>
<div class="nav-group" aria-label="Carousel navigation">
<span class="hint">Drag · Scroll · ← →</span>
<button type="button" id="prev" aria-label="Previous frame">
<svg width="14" height="10" viewBox="0 0 14 10" fill="none"><path d="M5 1L1 5L5 9M1 5H13" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
<button type="button" id="next" aria-label="Next frame">
<svg width="14" height="10" viewBox="0 0 14 10" fill="none"><path d="M9 1L13 5L9 9M13 5H1" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
</div>
</footer>
<div class="progress" id="progress" aria-hidden="true"></div>
<div class="kbd" aria-hidden="true">
Navigate <kbd>←</kbd> <kbd>→</kbd> · Jump <kbd>1–9</kbd>
</div>
<script>
const IMAGES = {"01-golden-dunes-sunrise-31415631.jpg": "data:image/jpeg;base64,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 truncated
const slides = [
{ f:"01-golden-dunes-sunrise-31415631.jpg", title:"Golden Crest", sub:"Sunrise Expedition",
desc:"First light grazes the slip-face at 06:14",
loc:"Erg Chebbi, Morocco", date:"MAR 2024", fmt:"Color Negative", time:"06:14" },
{ f:"02-camel-caravan-8227510.jpg", title:"The Caravan", sub:"Old Salt Route",
desc:"Six camels · two days · no water between",
loc:"Tinejdad → Mhamid", date:"NOV 2023", fmt:"Color Negative", time:"15:42" },
{ f:"03-lonely-tree-36893821.jpg", title:"Lone Acacia", sub:"A Single Anchor",
desc:"Roots reach seven meters to a vanished river",
loc:"Tassili n'Ajjer", date:"APR 2024", fmt:"Medium Format", time:"11:08" },
{ f:"04-aerial-dunes-10390368.jpg", title:"From Above", sub:"Aerial Survey",
desc:"Four hundred feet, the dunes resolve into prints",
loc:"Namib-Naukluft", date:"JUL 2025", fmt:"Digital · Drone", time:"08:30" },
{ f:"05-endless-dunes-16908866.jpg", title:"Quiet Vastness", sub:"Horizon Study",
desc:"By the third hour the eye invents its own coords",
loc:"Empty Quarter", date:"JAN 2025", fmt:"Color Negative", time:"14:20" },
{ f:"06-sunlit-dunes-28911994.jpg", title:"Half Asleep", sub:"Afternoon Study",
desc:"Three to four — sand becomes a chiaroscuro",
loc:"Sossusvlei, Namibia", date:"AUG 2024", fmt:"Color Slide", time:"15:55" },
{ f:"07-sahara-dune-11377054.jpg", title:"Soft Ripples", sub:"Wind Drawing",
desc:"Night wind redraws the surface, daily, unread",
loc:"Sahara, Algeria", date:"FEB 2025", fmt:"Medium Format", time:"07:02" },
{ f:"08-sunset-hills-road-20685425.jpg", title:"Road West", sub:"Last Light",
desc:"A line that promises nothing in particular",
loc:"Atacama, Chile", date:"SEP 2024", fmt:"Color Negative", time:"18:47" },
{ f:"09-rocky-terrain-28843252.jpg", title:"Ancient Stone", sub:"Geologic Time",
desc:"Older than the sand by 280 million years",
loc:"Wadi Rum, Jordan", date:"MAY 2024", fmt:"Black & White", time:"10:15" },
{ f:"10-saguaro-cactus-4061938.jpg", title:"Desert Guardian", sub:"Solitary Form",
desc:"First arm at around seventy-five years",
loc:"Sonoran, Arizona", date:"OCT 2023", fmt:"Color Slide", time:"17:30" },
{ f:"11-acacia-palms-28170329.jpg", title:"Oasis Edge", sub:"Living Boundary",
desc:"Palms end, sand begins — six degrees lost",
loc:"Siwa, Egypt", date:"DEC 2024", fmt:"Color Negative", time:"09:25" },
{ f:"12-vast-dunes-2106194.jpg", title:"Closing Frame", sub:"Infinite Field",
desc:"The desert refuses the last word",
loc:"Rub' al Khali", date:"JUN 2025", fmt:"Color Negative", time:"16:10" }
];
const N = slides.length;
let current = 0;
let isAnimating = false;
/* ===== stage: build assets (12, absolute stacked) ===== */
const stageInner = document.getElementById("stageInner");
slides.forEach((s, i) => {
const a = document.createElement("div");
a.className = "asset";
a.dataset.idx = i;
a.innerHTML = `<img src="${IMAGES[s.f]}" alt="${s.title}" loading="${i < 3 ? 'eager' : 'lazy'}">`;
stageInner.insertBefore(a, stageInner.firstChild);
});
const assetEls = stageInner.querySelectorAll(".asset");
/* ===== titles track: build slides ===== */
const track = document.getElementById("titlesTrack");
slides.forEach((s, i) => {
const el = document.createElement("div");
el.className = "slide";
el.dataset.idx = i;
el.innerHTML = `
<a class="title" href="#" data-idx="${i}">
<span class="title-text">${s.title}</span>
<span class="title-num">[${String(i+1).padStart(2,"0")}]</span>
</a>`;
track.appendChild(el);
});
const slideEls = track.querySelectorAll(".slide");
/* ===== dial dots (Pavilion 16-dot ring) ===== */
const dial = document.getElementById("dial");
const sizes = [2,2,2,2,2,2,3,5,8,10,12,10,8,5,3,2];
const opacs = [0,0,0,0,0,0,0.08,0.16,0.32,0.55,1,0.55,0.32,0.16,0.08,0];
for (let i = 0; i < 16; i++) {
const dot = document.createElement("span");
dot.className = "dot";
dot.style.setProperty("--i", i);
dot.style.setProperty("--s", sizes[i] + "px");
dot.style.setProperty("--o", opacs[i]);
dial.appendChild(dot);
}
/* ===== project-info lines (each is a stack of spans) ===== */
function buildStack(el, values, prefix = "", suffix = "") {
el.innerHTML = "";
values.forEach((v, i) => {
const span = document.createElement("span");
span.textContent = prefix + v + suffix;
span.dataset.idx = i;
if (i === 0) span.classList.add("is-active");
el.appendChild(span);
});
}
const piNum = document.getElementById("piNum");
const piSub = document.getElementById("piSub");
const piLoc = document.getElementById("piLoc");
buildStack(piNum, slides.map((_, i) => "Frame " + String(i+1).padStart(2,"0")));
buildStack(piSub, slides.map(s => s.sub));
buildStack(piLoc, slides.map(s => s.loc));
/* ===== facts stack ===== */
const factDate = document.getElementById("factDate");
const factFmt = document.getElementById("factFmt");
const factTime = document.getElementById("factTime");
buildStack(factDate, slides.map(s => s.date));
buildStack(factFmt, slides.map(s => s.fmt));
buildStack(factTime, slides.map(s => s.time));
/* ===== photo caption loc stack ===== */
const pcLoc = document.getElementById("pcLoc");
buildStack(pcLoc, slides.map(s => s.loc));
/* ===== description ribbon ===== */
const descText = document.getElementById("descText");
slides.forEach((s, i) => {
const p = document.createElement("p");
p.textContent = s.desc;
p.dataset.idx = i;
if (i === 0) p.classList.add("is-active");
descText.appendChild(p);
});
/* ===== progress ticks ===== */
const progress = document.getElementById("progress");
for (let i = 0; i < N; i++) progress.appendChild(document.createElement("span"));
const progEls = progress.querySelectorAll("span");
document.getElementById("tot").textContent = String(N).padStart(2, "0");
const pcFrame = document.getElementById("pcFrame");
const cur = document.getElementById("cur");
/* ===== modular offset helper ===== */
function modOffset(i, c, n) {
let off = i - c;
if (off > n / 2) off -= n;
if (off < -n / 2) off += n;
return off;
}
/* ===== render ===== */
function render() {
// title wheel — Pavilion's exact transform values
slideEls.forEach((el, i) => {
const off = modOffset(i, current, N);
let ty, rot, op;
if (off === 0) { ty = -50; rot = 0; op = 1; }
else if (off === -1){ ty = -150; rot = 45; op = 0.33; }
else if (off === 1) { ty = 50; rot = -45; op = 0.33; }
else if (off === -2){ ty = -250; rot = 90; op = 0; }
else if (off === 2) { ty = 150; rot = -90; op = 0; }
else {
// far-out slides — pinned beyond ±2
const dir = off < 0 ? -1 : 1;
ty = dir === -1 ? -250 : 150;
rot = dir === -1 ? 90 : -90;
op = 0;
}
el.style.transform = `translateY(${ty}%) rotate(${rot}deg)`;
el.style.opacity = op;
el.classList.toggle("is-active", off === 0);
});
// stage assets — modular slide
assetEls.forEach((el, i) => {
const off = modOffset(i, current, N);
el.classList.toggle("is-active", off === 0);
el.classList.toggle("is-prev", off === -1);
el.classList.toggle("is-next", off === 1);
el.classList.toggle("is-far-prev", off < -1);
el.classList.toggle("is-far-next", off > 1);
});
// active-class swaps on stacks
[piNum, piSub, piLoc, factDate, factFmt, factTime, pcLoc, descText].forEach(host => {
host.querySelectorAll("span, p").forEach(c => {
c.classList.toggle("is-active", parseInt(c.dataset.idx, 10) === current);
});
});
// dial rotation — Pavilion base 135° + step per slide
const step = 360 / N;
dial.style.setProperty("--dial-rotation", `${135 + current * step}deg`);
// counter + caption
cur.textContent = String(current + 1).padStart(2, "0");
pcFrame.textContent = `FRAME ${String(current + 1).padStart(2,"0")} / ${String(N).padStart(2,"0")}`;
// progress
progEls.forEach((el, i) => {
el.classList.toggle("active", i === current);
el.classList.toggle("passed", i < current);
});
}
function go(delta) {
if (isAnimating) return;
isAnimating = true;
current = (current + delta + N) % N;
render();
setTimeout(() => { isAnimating = false; }, 380);
}
function jump(i) {
if (isAnimating || i === current) return;
isAnimating = true;
current = ((i % N) + N) % N;
render();
setTimeout(() => { isAnimating = false; }, 380);
}
/* ===== controls ===== */
document.getElementById("prev").addEventListener("click", () => go(-1));
document.getElementById("next").addEventListener("click", () => go(1));
track.addEventListener("click", e => {
const t = e.target.closest(".title");
if (!t) return;
e.preventDefault();
jump(parseInt(t.dataset.idx, 10));
});
window.addEventListener("keydown", e => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); go(1); }
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); go(-1); }
else if (e.key === "Home") jump(0);
else if (e.key === "End") jump(N - 1);
else if (/^[1-9]$/.test(e.key)) jump(parseInt(e.key, 10) - 1);
else if (e.key === "0") jump(9);
});
/* ===== wheel ===== */
let wheelLock = false;
const onWheel = e => {
e.preventDefault();
if (wheelLock) return;
const d = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
if (Math.abs(d) < 8) return;
go(d > 0 ? 1 : -1);
wheelLock = true;
setTimeout(() => { wheelLock = false; }, 480);
};
document.getElementById("stage").addEventListener("wheel", onWheel, { passive: false });
document.querySelector(".info").addEventListener("wheel", onWheel, { passive: false });
/* ===== drag / swipe ===== */
const stage = document.getElementById("stage");
let pointer = null;
stage.addEventListener("pointerdown", e => {
pointer = { x: e.clientX, y: e.clientY };
stage.classList.add("dragging");
stage.setPointerCapture(e.pointerId);
});
stage.addEventListener("pointermove", e => {
if (!pointer) return;
const dx = e.clientX - pointer.x;
const dy = e.clientY - pointer.y;
if (Math.abs(dx) > 60 || Math.abs(dy) > 60) {
const d = Math.abs(dx) > Math.abs(dy) ? dx : dy;
go(d < 0 ? 1 : -1);
pointer = null;
stage.classList.remove("dragging");
}
});
stage.addEventListener("pointerup", () => { pointer = null; stage.classList.remove("dragging"); });
stage.addEventListener("pointercancel", () => { pointer = null; stage.classList.remove("dragging"); });
/* ===== minor: menu / CTA feedback ===== */
document.querySelectorAll(".menu a, .menu button, .description > a").forEach(el => {
el.addEventListener("click", e => {
e.preventDefault();
if (el.id === "cta") { go(1); return; }
});
});
/* ===== initial render ===== */
render();
</script>
</body>
</html>