# Shopify Editions Winter '26 拆解：复刻指南

> 目标：给出可直接拷走的 token、公式与关键代码，以及「为什么选这个技术」的决策表与工程坑。所有值为原站原值（实锤），近似处已注明。

## 一、:root token（可复制）

```css
:root{
  /* 色 */
  --paper:#f7f7ee;        /* 页面纸 */
  --ink:#292919;          /* 橄榄墨 */
  --grey-light:#dcdcd0;   /* 幕布纸（WebGL overlay 同色） */
  --nav-grey:#e2e2d9;     /* light 态导航 */
  --grey-mid:#909083;     /* light 态构造线 */
  --grey-dark:#5c5c4e;    /* 正文链接 */
  --sidekick:#8051ff;     /* 唯一品牌强调 */
  --glow:#88DDFF;         /* 蝶翼辉光 */
  --tag-line:#D7D7CF;     /* 星座连线 */
  --popup-border:#434343;

  /* 缓动（全站四支主曲线） */
  --ease-back:cubic-bezier(.34,1.56,.64,1);   /* 回弹：卡片入场、弹窗、缩放 */
  --ease-frame:cubic-bezier(.72,.16,.19,.96); /* 收框、标题滑移 */
  --ease-lines:cubic-bezier(.34,.22,.47,.84); /* 构造线描线 3s */
  --ease-fade:cubic-bezier(.25,.46,.45,.94);  /* 淡出 .52s */

  /* 栅格 */
  --grid-gap:clamp(12px,1vw,16px);
  --container:105rem;
}
```

字阶核心：

```css
.headline-1{font:700 72px/0.9 NeueMontreal;letter-spacing:-.03em;
  text-box:trim-both cap alphabetic}
@media(min-width:48.0625rem){.headline-1{font-size:clamp(7.5rem,35.79px + 10.965vw,13.75rem)}}
.narrative-1{font:400 28px/0.96 HWCigars;letter-spacing:-.05em}
@media(min-width:48.0625rem){.narrative-1{font-size:clamp(2rem,18.53px + 1.754vw,3rem)}}
.drop-cap::first-letter{font-family:ImperialScript;line-height:0;
  font-size:clamp(6rem,75.79px + 2.63vw,7.5rem)}
```

## 二、八个签名效果的复现代码

### 1. 达·芬奇构造线（按章点亮）

```css
.davinci path,.davinci circle{
  stroke:#ffffff26;stroke-width:.4;fill:none;
  stroke-dasharray:1;stroke-dashoffset:1px;   /* 配合 pathLength="1" */
  transition:stroke-dashoffset 3s var(--ease-lines),
             stroke 3s var(--ease-lines) 2.1s; /* 颜色延迟 70% */
}
.davinci[data-active="6"] [data-group~="6"]{stroke-dashoffset:0}
@keyframes line-fade{0%{stroke:#ffffffbf}to{stroke:#ffffff26}}
.davinci [data-just-shown]{animation:line-fade 4s ease-out forwards}
```

要点：每条线写 `pathLength="1"`，长短不一的线共享同一套 dash 数学；线组归属放 `data-group="1,3,5"`；章切换只改容器一个 `data-active`。黄金螺旋台阶的三段长度比走 φ（80.25→49.04→30.65）。

### 2. 罗马数字（目录与商店）

```css
nav li a::after{
  counter-increment:nav-item;
  content:counters(nav-item,".",upper-roman);
}
.qty-value::after{content:counter(q,upper-roman);counter-reset:q var(--q,1)}
/* 悬停价格翻转 */
.price{overflow:hidden;height:30px}
.price .row{transition:transform .3s ease-in-out}
.price:hover .row{transform:translateY(-100%)}
```

JS 只改 `--q` / `--d` / `--c` 变量，渲染交给 CSS counter。

### 3. 滚动驱动 Theatre.js（架构核心）

```js
const sheet = getProject("SidekickScene", { state: cmsJson }).sheet("Scene");
// 进度换算：时间轴长度按「章高+视口」归一
const scale = (sectionH + vh) / vh;
store.subscribe(s => s.sections[i].progress, p => {
  sheet.sequence.position = clamp01(p) * scale + introOffset;
});
// Hero 序曲：offset 从 -1 → 0，5s cubic ease-out，仅冷启动播放
```

移动端（原生滚动）再加一层每帧 `pos += (target-pos)*0.5` 平滑，且只在 |activeSection−i|≤1 时运行。

### 4. 技能星座（邻近物理）

```js
const k = Math.min(dist/400, 1);
scale   = 2.1 - (2.1-0.7)*k;      // 指针 0px→2.1 倍，400px→0.7
opacity = 1   - (1-0.4)*k;
// 斥力：对每个 baseScale>1 的邻居，沿连线推开
push = 80 * (neighborScale-1) * max(0, 1 - d/400);
// 连线：每 120° 扇区取最近邻（三扇区），端点 300ms ease-out
```

弹窗：`grid-template-rows:0fr→1fr` + `--ease-back` 300ms delay 50ms；方向按象限翻转五个自定义属性。计数上升：1s、`1-(1-t)^3`。

### 5. 蝴蝶扑翼（顶点着色器核心）

```glsl
float flapSpeed = 4.0 + rand.y * 4.0;
float flapAmp   = 0.8 + rand.z * 0.5;
float t = uTime * uSpeed * flapSpeed + rand.x * 6.28;   // uSpeed=1.2
float wave = sin(t) + sin(t*2.1 + rand.w*3.14)*0.2
                    + sin(t*0.37 + rand.z*5.0)*0.3;      // 三层叠波
float wingBlend = smoothstep(0.02, 0.4, abs(pos.x));     // 根部不转
float asym = 1.0 + sign(pos.x)*sin(t*0.7 + rand.w*6.28)*0.08;
float ang = wave * flapAmp * wingBlend * asym;           // 绕体轴旋转
pos.y += smoothstep(1.0, -0.5, wave) * 0.25;             // 拍翅反升
pos *= uScale * (0.8 + rand.x * 0.4);                    // uScale=.08
```

片元：HSL 色相偏移（uHue + rand×.05）、边缘辉光 `#88DDFF × (sin(3t)*.3+.7)`、`pow(color,1.5)`。上限 50 只。

### 6. 幕布擦除（章节转场）

Theatre 轨道三件套：`overlay.position ∈ [−1,1]`（另一条 mobilePosition）、`overlay.color`（默认 #dcdcd0）、`darken ∈ [0,1]` + `fadeCenter(xyz)`。DOM 主题跟随 position 窗口切换（sidebar [−.85,.15]、title [−.3,.7]、nav [−.25,.75]），切换帧上 `data-*-immediate` 禁过渡防闪。

### 7. /key 双窗透镜（彩蛋架构骨架）

```js
/* 主窗（发送端）：画布直播 + 三条 rAF 广播 */
const stream = canvas.captureStream(60);                    // 每 16ms requestFrame 保帧
const pc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.l.google.com:19302'}]});
stream.getTracks().forEach(t => pc.addTrack(t, stream));    // maxBitrate 5e6 / maxFramerate 60
// 信令走 BroadcastChannel('webrtc-signaling')：offer 每 100ms 重发直到 answer（10s 超时）
// rAF 1: bc.send('canvas-rect', canvas.getBoundingClientRect())
// rAF 2: bc.send('main-window', {screenX, screenY, w, h, scroll: lenis.scroll})
//        bc.send('active-section', {handle, progress, theatreTime: progress*(secH+vh)/vh})

/* 弹窗（接收端）：屏幕级对齐 = 透镜幻觉的全部数学 */
const offsetX = (mainWindow.x + canvasRect.x + canvasRect.w/2) - (window.screenX + innerWidth/2);
const visibleH = 2 * Math.tan(fov/2) * dist;                // fov 50、planeZ −5
plane.x += ((offsetX / innerWidth) * visibleW - plane.x) * 0.25;   // lerp .25
plane.scale.set(visibleW * canvasRect.w/innerWidth, visibleH * canvasRect.h/innerHeight);

/* 开锁判定（弹窗内，世界系） */
if (!model.isCentered && Math.hypot(model.x - keyhole.x, model.y - keyhole.y) < 2) {
  bc.send('key-insert-complete');    // 主窗：Theatre coinRain 轨 → 150 枚 instanced 金币
  setTimeout(switchToGraffiti, 2000); // 弹窗：blacklight 着色器 + 涂鸦长卷加法叠层
}
```

- 生存纪律照抄：弹窗 blur 10s / document.hidden 9s 自关；video.currentTime 冻结 3 秒×3 → 重连（≤3 次）
- 收藏品配方：GLB → 全 mesh 强制金漆（#BF9852、metalness 1、roughness .2、envMapIntensity 2、仓库 HDR）；退场用 onBeforeCompile 注入 fbm3d 溶解（阈值边缘 rgb×10 描边）
- Boids 彩带：位置链 `path[j].lerp(path[j-1], .28)`；帯宽 `.06 × pow(1−t,.3)·pow(t+.1,.5)`；体波两频叠加 ×t²；AdditiveBlending + depthWrite off

### 8. Theatre 关键帧求值（读 state JSON 直接回放）

```js
/* keyframe: {position, value, handles: [inX, inY, outX, outY]} */
function evalTrack(kfs, pos) {
  const i = kfs.findIndex((k, j) => pos >= k.position && pos <= kfs[j+1]?.position);
  if (i < 0) return (pos <= kfs[0].position ? kfs[0] : kfs.at(-1)).value;
  const a = kfs[i], b = kfs[i+1], u = (pos - a.position) / (b.position - a.position);
  return a.value + (b.value - a.value) * cubicBezierY(a.handles[2], a.handles[3], b.handles[0], b.handles[1], u);
}   // cubic-bezier(左帧右手柄, 右帧左手柄)——Theatre 同式；滚动接入：pos = progress×(secH+vh)/vh
```

## 三、选型决策表

| 需求 | 原站选择 | 为什么 |
| --- | --- | --- |
| 十二章各自成戏的编排 | Theatre.js + CMS 状态 | 设计师在编辑器排关键帧，热更不发版；代码只留物理 |
| 油画世界 | 多层图像平面 + 相机推拉 | 全 3D 成本高且失画感；平面 + bloom + 粒子够「活」 |
| 矢量微交互（卡内动画） | Rive | 状态机 + 事件回调（showCanvas/hideCanvas）+ 文本注入 |
| 数据流示意 | Canvas 2D | 60 条贝塞尔管线不值得开 WebGL 上下文 |
| 平滑滚动 | Lenis（仅桌面） | 移动端劫持触摸链毁惯性——原生滚动 + JS lerp |
| 画质适配 | detect-gpu 四档 + 运行时降级 | 一套内容三档特效，移动端整级降静态图 |
| 灯箱 | View Transitions API | 卡片媒体 morph 进灯箱一行 CSS 命名搞定，浏览器兜底优雅 |
| 纹理 | KTX2 + ZSTD + worker 池流式 | GPU 直传省内存；80 字节头先行可提前建纹理 |

## 四、工程坑（每条都是原站踩过并解掉的）

1. **KTX2 不流式必卡**：读满 80 字节先解析头、分层边下边传，否则首屏白等整包。
2. **移动端不要 Lenis**：触摸链一劫持，iOS 惯性就死。原生滚动 + 场景侧 0.5 lerp。
3. **View Transition 跳形**：`::view-transition-old` 用 `object-fit:contain`、new 用 `cover`，否则媒体比例切换时抽搐。
4. **罗马数字宽度突变**：I→VIII 宽度差 4 倍，容器必须预留（原站 min-width 48px + 居中）。
5. **主题切换闪黑**：先同帧翻三个 data 属性 + `data-*-immediate` 禁过渡，下一帧移除。
6. **视频解码器泄漏**：退出视口必须 `source.remove()` + `video.load()`，不然十几个卡片视频耗尽解码器。
7. **确定性随机**：SSR 场景一切「随机」布局用坐标 hash（`sin(i·12.9898+s·78.233)·43758.5453`），否则水合闪跳。
8. **`prefers-reduced-motion` 全链路**：序曲跳过、Lenis 保留但语义不变、SMIL 不自动播、View Transition 关。
9. **暖白非纯白**：#f7f7ee 与 #dcdcd0 之间要靠 WebGL 幕布同色缝合，纯白会把画撕开。
10. **弹窗滚轮**：跨 iframe/弹层 wheel 事件广播回主页面，每帧只吸收 15% 才不晕。
