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 CLOCK_W = 4 * CW - 36;
const DEFAULT_WEIGHT = 300;
/** The story runs from 0 to 9: chapters 1 to 7 take one unit each, chapter 8 two. */
const STORY_END = 9;
/** The story runs from 0 to 10: chapters 1 to 7 take one unit each, chapter 8 three. */
const STORY_END = 10;
const LONG = 3;
/** Icon shapes, as border radii for icons and for the picture widget. */
const SHAPES = {
squircle: { icon: '30%', widget: '30px' },
@@ -529,13 +530,13 @@
const s = blank();
P = clamp(P, 0, STORY_END);
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.
const t = (P - 7) / 2;
s.drawer = 1 - E(t, 0, 0.1);
const t = (P - 7) / LONG;
s.drawer = 1 - E(t, 0, 0.06);
s.list = ph.scrollFor(L('P'));
s.hidden = { Podcasts: 1 };
s.snack = 1 - E(t, 0, 0.08);
s.snack = 1 - E(t, 0, 0.05);
return s;
}
const ch = Math.min(6, Math.floor(P));
@@ -648,9 +649,16 @@
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;
const layersApart = (t) => smooth(seg(t, 0.14, 0.42)) * (1 - smooth(seg(t, 0.74, 0.94)));
/**
* Chapter 8, from its start (0) to its end (1): when the phones swap, how far apart the layers
* 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) {
const box = document.querySelector('.story__steps');
@@ -667,14 +675,13 @@
const P = clamp((-box.getBoundingClientRect().top + vh / 2) / unit, 0, STORY_END);
const ch = P >= 7 ? 7 : Math.floor(P);
// 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) {
lastP = shown;
ph.render(storyState(ph, shown));
if (layered) {
const t = shown >= 7 ? (shown - 7) / 2 : 0;
const swapped = shown >= 7 && t >= SWAP_AT;
layered.set(swapped ? layersApart(t) : 0, swapped, force);
const t = shown >= 7 ? (shown - 7) / LONG : 0;
layered.set(t, shown >= 7 && t >= SWAP_AT, force);
}
}
if (ch !== lastChapter) {
@@ -1610,21 +1617,19 @@
svg.append(path, dot);
return { path, dot };
});
const LABEL_W = 214;
const GAP = 165; // between layers, in the phone's pixels
let scale = 1;
let lastE = -1;
let lastT = -1;
let swapped = null;
/** Same picture as the story phone shows, so the swap is not seen. */
function syncPictures() {
pics.forEach((p, i) => p.classList.toggle('on', i === storyPhone.picIndex));
}
/** Puts the layers where e (0 closed, 1 fully apart) wants them. */
function arrange(e) {
const GAP = 150; // between layers, in the phone's pixels
const wide = () => window.innerWidth > 900;
function place(e, shift) {
const tilt = 56 * e;
const sc = scale * (1 - 0.36 * 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 sc = scale * (1 - 0.44 * e);
const ox = 207 * (scale - sc) + shift;
// 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;
@@ -1633,66 +1638,97 @@
layers.forEach((l, i) => {
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);
svg.style.opacity = shown.toFixed(3);
labels.forEach((li) => { li.style.opacity = shown.toFixed(3); });
if (window.innerWidth <= 900 || shown <= 0) return;
svg.style.opacity = shown > 0 ? '1' : '0';
if (!wide() || shown <= 0) {
labels.forEach((li) => { li.style.opacity = '0'; });
return;
}
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 r = pin.getBoundingClientRect();
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 = [];
let prev = -Infinity;
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);
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;
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)`;
const { x, y } = pts[k];
const bend = Math.max(x + 16, colX - 26);
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)}`);
const d = Math.abs(ys[k] - y) < 0.5
? `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('cy', y.toFixed(1));
lines[k].dot.style.opacity = (shown * lit).toFixed(3);
});
}
const fit = () => {
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();
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) {
swapped = on;
host.style.visibility = on ? 'visible' : 'hidden';
storyPhone.host.style.visibility = on ? 'hidden' : '';
if (on) syncPictures();
lastE = -1;
lastT = -1;
}
if (!on) {
label(0);
label(0, 0, []);
rail.style.opacity = '';
return;
}
if (!force && Math.abs(e - lastE) < 0.0005) return;
lastE = e;
if (!force && Math.abs(t - lastT) < 0.0002) return;
lastT = t;
const e = layersApart(t);
arrange(e);
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 };
}
+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 .xhost { visibility: hidden; }
/* 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)); }
.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; }
@@ -786,17 +786,20 @@ section { position: relative; }
.xl--list .list { top: 46px; }
.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__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 path { fill: none; stroke: var(--cyan); stroke-width: 2; }
.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 {
position: absolute; left: 0; top: 0; z-index: 3; width: 214px; translate: 0 -50%;
padding: 9px 12px 10px; background: rgba(7, 7, 13, .94);
border: var(--b) solid var(--fg); box-shadow: 4px 4px 0 var(--violet);
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 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; }