Website: chapter 8 labels on the layers' corners, one layer at a time; phone centred

Labels: each layer's pin now sits on its outer rounded corner, the
layer's rightmost point, so a tag straight across from it is joined by
a short line that crosses nothing; the stack moves left by just enough
for the tags to fit, and the open stack is a little smaller so it keeps
clear of the chapter's heading. While the stack is open, scrolling walks
through the layers from the glass to the wallpaper: the one looked at
stays bright and its tag lights up cyan, the others dim (where the tags
are shown). Chapter 8 is three screens long for it.

Centring: the labels' list sat in the stage's grid flow, so the grid
split its height into two rows and the story phone was centred in the
top one (about 100 px high on a 1146 px window). It is out of the flow
now; measured equal gaps above and below at 2000 x 1146, 1440 x 900
and 1280 x 720.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TMCandClaude Opus 5.5 committed 2026-11-12 22:20:10 +01:00
1 parent 058fecd27f
commit 0fbefd2ca6
2 files changed
+85 -46

No files matched your search

+79 -43
View File
@@ -123,8 +123,9 @@
const MENU_X = 120; const MENU_X = 120;
const CLOCK_W = 4 * CW - 36; const CLOCK_W = 4 * CW - 36;
const DEFAULT_WEIGHT = 300; const DEFAULT_WEIGHT = 300;
/** The story runs from 0 to 9: chapters 1 to 7 take one unit each, chapter 8 two. */ /** The story runs from 0 to 10: chapters 1 to 7 take one unit each, chapter 8 three. */
const STORY_END = 9; const STORY_END = 10;
const LONG = 3;
/** Icon shapes, as border radii for icons and for the picture widget. */ /** Icon shapes, as border radii for icons and for the picture widget. */
const SHAPES = { const SHAPES = {
squircle: { icon: '30%', widget: '30px' }, squircle: { icon: '30%', widget: '30px' },
@@ -529,13 +530,13 @@
const s = blank(); const s = blank();
P = clamp(P, 0, STORY_END); P = clamp(P, 0, STORY_END);
if (P >= 7) { if (P >= 7) {
// Chapter 8, two units long: the list closes and the phone is home again. From there on the // Chapter 8, three units long: the list closes and the phone is home again. From there on the
// layered phone (setupAnatomy) takes its place and comes apart; this one is hidden. // layered phone (setupAnatomy) takes its place and comes apart; this one is hidden.
const t = (P - 7) / 2; const t = (P - 7) / LONG;
s.drawer = 1 - E(t, 0, 0.1); s.drawer = 1 - E(t, 0, 0.06);
s.list = ph.scrollFor(L('P')); s.list = ph.scrollFor(L('P'));
s.hidden = { Podcasts: 1 }; s.hidden = { Podcasts: 1 };
s.snack = 1 - E(t, 0, 0.08); s.snack = 1 - E(t, 0, 0.05);
return s; return s;
} }
const ch = Math.min(6, Math.floor(P)); const ch = Math.min(6, Math.floor(P));
@@ -648,9 +649,16 @@
return s; return s;
} }
/** Chapter 8, from its start (0) to its end (1): when the phones swap, and how far apart the layers are. */ /**
const SWAP_AT = 0.12; * Chapter 8, from its start (0) to its end (1): when the phones swap, how far apart the layers
const layersApart = (t) => smooth(seg(t, 0.14, 0.42)) * (1 - smooth(seg(t, 0.74, 0.94))); * are, and which layer is looked at (0 the glass to 4 the wallpaper) while the stack is open.
*/
const SWAP_AT = 0.08;
const layersApart = (t) => smooth(seg(t, 0.09, 0.26)) * (1 - smooth(seg(t, 0.85, 0.97)));
const lookingAt = (t) => ({
amount: smooth(seg(t, 0.27, 0.32)) * (1 - smooth(seg(t, 0.78, 0.83))),
at: seg(t, 0.33, 0.77) * 4,
});
function setupStory(ph, layered) { function setupStory(ph, layered) {
const box = document.querySelector('.story__steps'); const box = document.querySelector('.story__steps');
@@ -667,14 +675,13 @@
const P = clamp((-box.getBoundingClientRect().top + vh / 2) / unit, 0, STORY_END); const P = clamp((-box.getBoundingClientRect().top + vh / 2) / unit, 0, STORY_END);
const ch = P >= 7 ? 7 : Math.floor(P); const ch = P >= 7 ? 7 : Math.floor(P);
// Without motion, each chapter shows its finished picture (chapter 8: pulled apart). // Without motion, each chapter shows its finished picture (chapter 8: pulled apart).
const shown = reduced ? (ch === 7 ? 7.98 : ch + 0.985) : P; const shown = reduced ? (ch === 7 ? 7 + LONG * 0.2 : ch + 0.985) : P;
if (force || Math.abs(shown - lastP) > 0.0002) { if (force || Math.abs(shown - lastP) > 0.0002) {
lastP = shown; lastP = shown;
ph.render(storyState(ph, shown)); ph.render(storyState(ph, shown));
if (layered) { if (layered) {
const t = shown >= 7 ? (shown - 7) / 2 : 0; const t = shown >= 7 ? (shown - 7) / LONG : 0;
const swapped = shown >= 7 && t >= SWAP_AT; layered.set(t, shown >= 7 && t >= SWAP_AT, force);
layered.set(swapped ? layersApart(t) : 0, swapped, force);
} }
} }
if (ch !== lastChapter) { if (ch !== lastChapter) {
@@ -1610,21 +1617,19 @@
svg.append(path, dot); svg.append(path, dot);
return { path, dot }; return { path, dot };
}); });
const LABEL_W = 214;
const GAP = 165; // between layers, in the phone's pixels
let scale = 1; let scale = 1;
let lastE = -1; let lastT = -1;
let swapped = null; let swapped = null;
/** Same picture as the story phone shows, so the swap is not seen. */ /** Same picture as the story phone shows, so the swap is not seen. */
function syncPictures() { function syncPictures() {
pics.forEach((p, i) => p.classList.toggle('on', i === storyPhone.picIndex)); pics.forEach((p, i) => p.classList.toggle('on', i === storyPhone.picIndex));
} }
/** Puts the layers where e (0 closed, 1 fully apart) wants them. */ const wide = () => window.innerWidth > 900;
function arrange(e) { function place(e, shift) {
const GAP = 150; // between layers, in the phone's pixels
const tilt = 56 * e; const tilt = 56 * e;
const sc = scale * (1 - 0.36 * e); const sc = scale * (1 - 0.44 * e);
const wide = window.innerWidth > 900;
// Opening, the stack moves left to leave room for its labels.
const shift = wide ? -e * Math.min(140, stage.clientWidth * 0.17) : 0;
const ox = 207 * (scale - sc) + shift; const ox = 207 * (scale - sc) + shift;
// The layers rise as they separate; lower the whole stack by half of that, to keep it centred. // The layers rise as they separate; lower the whole stack by half of that, to keep it centred.
const oy = 434 * (scale - sc) + e * 2.5 * GAP * Math.sin((tilt * Math.PI) / 180) * sc; const oy = 434 * (scale - sc) + e * 2.5 * GAP * Math.sin((tilt * Math.PI) / 180) * sc;
@@ -1633,66 +1638,97 @@
layers.forEach((l, i) => { layers.forEach((l, i) => {
l.style.transform = i ? `translateZ(${(i * GAP * e).toFixed(1)}px)` : ''; l.style.transform = i ? `translateZ(${(i * GAP * e).toFixed(1)}px)` : '';
}); });
// Flat, it is just a home screen: the app list layer only shows once the stack opens.
layers[4].style.opacity = smooth(seg(e, 0.15, 0.6)).toFixed(3);
} }
/** The labels: one column beside the stack, evenly spaced, each joined to its layer's edge. */ /**
function label(e) { * Puts the layers where e (0 closed, 1 fully apart) wants them. The pins sit on each layer's
* outer corner, its rightmost point; if the labels beside them would not fit, the stack moves
* left by just enough.
*/
function arrange(e) {
place(e, 0);
if (!wide() || e <= 0) return;
const room = stage.getBoundingClientRect().right - 12 - LABEL_W - 34;
let corner = -Infinity;
for (const pin of pins) corner = Math.max(corner, pin.getBoundingClientRect().left);
if (corner > room) place(e, room - corner);
}
/** Each label straight across from its layer's corner, on a short line that crosses nothing. */
function label(e, focus, weights) {
const shown = seg(e, 0.55, 0.95); const shown = seg(e, 0.55, 0.95);
svg.style.opacity = shown.toFixed(3); svg.style.opacity = shown > 0 ? '1' : '0';
labels.forEach((li) => { li.style.opacity = shown.toFixed(3); }); if (!wide() || shown <= 0) {
if (window.innerWidth <= 900 || shown <= 0) return; labels.forEach((li) => { li.style.opacity = '0'; });
return;
}
const base = stage.getBoundingClientRect(); const base = stage.getBoundingClientRect();
let right = -Infinity;
for (const l of layers) right = Math.max(right, l.getBoundingClientRect().right);
const LABEL_W = 214;
const colX = clamp(right - base.left + 34, 0, base.width - LABEL_W - 8);
const pts = pins.map((pin) => { const pts = pins.map((pin) => {
const r = pin.getBoundingClientRect(); const r = pin.getBoundingClientRect();
return { x: r.left - base.left, y: r.top - base.top }; return { x: r.left - base.left, y: r.top - base.top };
}); });
const colX = Math.min(Math.max(...pts.map((pt) => pt.x)) + 34, base.width - LABEL_W - 12);
const ys = []; const ys = [];
let prev = -Infinity; let prev = -Infinity;
for (const pt of pts) { for (const pt of pts) {
const y = Math.max(pt.y, prev + 76); // never closer than one tag's height const y = Math.max(pt.y, prev + 74); // never closer than one tag's height
ys.push(y); ys.push(y);
prev = y; prev = y;
} }
const spill = ys[ys.length - 1] + 44 - base.height; const spill = ys[ys.length - 1] + 40 - base.height;
if (spill > 0) for (let k = 0; k < ys.length; k++) ys[k] -= spill; if (spill > 0) for (let k = 0; k < ys.length; k++) ys[k] -= spill;
labels.forEach((li, k) => { labels.forEach((li, k) => {
const lit = 1 - focus * (1 - Math.max(0.35, weights[k]));
li.style.opacity = (shown * lit).toFixed(3);
li.classList.toggle('is-on', focus > 0.5 && weights[k] > 0.5);
li.style.transform = `translate(${colX.toFixed(1)}px, ${ys[k].toFixed(1)}px)`; li.style.transform = `translate(${colX.toFixed(1)}px, ${ys[k].toFixed(1)}px)`;
const { x, y } = pts[k]; const { x, y } = pts[k];
const bend = Math.max(x + 16, colX - 26); const d = Math.abs(ys[k] - y) < 0.5
lines[k].path.setAttribute('d', `M${x.toFixed(1)} ${y.toFixed(1)} L${bend.toFixed(1)} ${ys[k].toFixed(1)} L${colX.toFixed(1)} ${ys[k].toFixed(1)}`); ? `M${x.toFixed(1)} ${y.toFixed(1)} H${colX.toFixed(1)}`
: `M${x.toFixed(1)} ${y.toFixed(1)} H${(x + 12).toFixed(1)} L${(colX - 12).toFixed(1)} ${ys[k].toFixed(1)} H${colX.toFixed(1)}`;
lines[k].path.setAttribute('d', d);
lines[k].path.style.opacity = (shown * lit).toFixed(3);
lines[k].dot.setAttribute('cx', x.toFixed(1)); lines[k].dot.setAttribute('cx', x.toFixed(1));
lines[k].dot.setAttribute('cy', y.toFixed(1)); lines[k].dot.setAttribute('cy', y.toFixed(1));
lines[k].dot.style.opacity = (shown * lit).toFixed(3);
}); });
} }
const fit = () => { const fit = () => {
scale = host.clientWidth / 414; scale = host.clientWidth / 414;
arrange(Math.max(0, lastE)); // scaled from the start: at full size it would widen the page place(0, 0); // scaled from the start: at full size it would widen the page
lastT = -1;
}; };
fit(); fit();
if ('ResizeObserver' in window) new ResizeObserver(fit).observe(host); if ('ResizeObserver' in window) new ResizeObserver(fit).observe(host);
function set(e, on, force) { /** t: where chapter 8 is, from 0 to 1. on: whether this phone is the one shown. */
function set(t, on, force) {
if (on !== swapped) { if (on !== swapped) {
swapped = on; swapped = on;
host.style.visibility = on ? 'visible' : 'hidden'; host.style.visibility = on ? 'visible' : 'hidden';
storyPhone.host.style.visibility = on ? 'hidden' : ''; storyPhone.host.style.visibility = on ? 'hidden' : '';
if (on) syncPictures(); if (on) syncPictures();
lastE = -1; lastT = -1;
} }
if (!on) { if (!on) {
label(0); label(0, 0, []);
rail.style.opacity = ''; rail.style.opacity = '';
return; return;
} }
if (!force && Math.abs(e - lastE) < 0.0005) return; if (!force && Math.abs(t - lastT) < 0.0002) return;
lastE = e; lastT = t;
const e = layersApart(t);
arrange(e); arrange(e);
rail.style.opacity = (1 - e).toFixed(3); rail.style.opacity = (1 - e).toFixed(3);
label(e); // One layer at a time: the one looked at stays bright and its label lights up, the others
// dim. Only where the labels are shown.
const look = lookingAt(t);
const focus = wide() ? look.amount : 0;
const weights = labels.map((_, k) => clamp(1 - Math.abs(k - look.at)));
layers.forEach((l, i) => {
const k = 5 - i; // the labels run from the glass (layer 5) down
const w = i === 0 ? 0 : weights[k];
const base = i === 4 ? smooth(seg(e, 0.15, 0.6)) : 1; // flat, the list layer is not there
l.style.opacity = (base * (1 - focus * (1 - Math.max(0.16, w)))).toFixed(3);
});
label(e, focus, weights);
} }
return { set, syncPictures, element: xp }; return { set, syncPictures, element: xp };
} }
+6 -3
View File
@@ -293,7 +293,7 @@ section { position: relative; }
.story__phones .phone-host, .story__phones .xhost { position: absolute; inset: 0; height: auto; } .story__phones .phone-host, .story__phones .xhost { position: absolute; inset: 0; height: auto; }
.story__phones .xhost { visibility: hidden; } .story__phones .xhost { visibility: hidden; }
/* Chapter 8 is two screens long; its text stays put while the phone comes apart. */ /* Chapter 8 is two screens long; its text stays put while the phone comes apart. */
.step.step--long { min-height: 200vh; min-height: 200svh; align-items: flex-start; } .step.step--long { min-height: 300vh; min-height: 300svh; align-items: flex-start; }
.step.step--long .step__card { position: sticky; top: max(96px, calc(50vh - 170px)); } .step.step--long .step__card { position: sticky; top: max(96px, calc(50vh - 170px)); }
.story__rail { position: absolute; right: 0; top: 50%; translate: 0 -50%; display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; } .story__rail { position: absolute; right: 0; top: 50%; translate: 0 -50%; display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; }
.story__rail li { width: 12px; height: 34px; border: var(--b) solid var(--fg); transition: background .3s, height .3s; } .story__rail li { width: 12px; height: 34px; border: var(--b) solid var(--fg); transition: background .3s, height .3s; }
@@ -786,17 +786,20 @@ section { position: relative; }
.xl--list .list { top: 46px; } .xl--list .list { top: 46px; }
.xl--list .alpha { bottom: 112px; } .xl--list .alpha { bottom: 112px; }
.xl--glass::before { content: ""; position: absolute; inset: 0; opacity: var(--e); background: linear-gradient(125deg, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0) 38%, rgba(255, 255, 255, .07) 68%, rgba(255, 255, 255, 0)); } .xl--glass::before { content: ""; position: absolute; inset: 0; opacity: var(--e); background: linear-gradient(125deg, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0) 38%, rgba(255, 255, 255, .07) 68%, rgba(255, 255, 255, 0)); }
.xl__pin { position: absolute; right: 0; top: 30%; width: 1px; height: 1px; } .xl__pin { position: absolute; right: 14px; bottom: 14px; width: 1px; height: 1px; }
.anatomy__lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 2; opacity: 0; } .anatomy__lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 2; opacity: 0; }
.anatomy__lines path { fill: none; stroke: var(--cyan); stroke-width: 2; } .anatomy__lines path { fill: none; stroke: var(--cyan); stroke-width: 2; }
.anatomy__lines circle { fill: var(--bg); stroke: var(--cyan); stroke-width: 2.5; } .anatomy__lines circle { fill: var(--bg); stroke: var(--cyan); stroke-width: 2.5; }
.anatomy__labels { list-style: none; margin: 0; padding: 0; } .anatomy__labels { position: absolute; inset: 0; list-style: none; margin: 0; padding: 0; pointer-events: none; } /* out of the flow, or the stage's grid would split its height and the phone sit high */
.anatomy__labels li { .anatomy__labels li {
position: absolute; left: 0; top: 0; z-index: 3; width: 214px; translate: 0 -50%; position: absolute; left: 0; top: 0; z-index: 3; width: 214px; translate: 0 -50%;
padding: 9px 12px 10px; background: rgba(7, 7, 13, .94); padding: 9px 12px 10px; background: rgba(7, 7, 13, .94);
border: var(--b) solid var(--fg); box-shadow: 4px 4px 0 var(--violet); border: var(--b) solid var(--fg); box-shadow: 4px 4px 0 var(--violet);
opacity: 0; pointer-events: none; will-change: transform; opacity: 0; pointer-events: none; will-change: transform;
} }
.anatomy__labels li { transition: border-color .25s, box-shadow .25s; }
.anatomy__labels li.is-on { border-color: var(--cyan); box-shadow: 4px 4px 0 var(--cyan); }
.anatomy__labels li.is-on b { color: var(--cyan); }
.anatomy__labels i { margin-right: 8px; font: 700 11px/1 var(--mono); font-style: normal; color: var(--cyan); } .anatomy__labels i { margin-right: 8px; font: 700 11px/1 var(--mono); font-style: normal; color: var(--cyan); }
.anatomy__labels b { font: 900 13px/1.15 var(--display); text-transform: uppercase; letter-spacing: -.01em; } .anatomy__labels b { font: 900 13px/1.15 var(--display); text-transform: uppercase; letter-spacing: -.01em; }
.anatomy__labels span { display: block; margin-top: 6px; font: 500 12px/1.3 var(--mono); color: var(--dim); white-space: nowrap; } .anatomy__labels span { display: block; margin-top: 6px; font: 500 12px/1.3 var(--mono); color: var(--dim); white-space: nowrap; }