Skip to content

Commit 58bc415

Browse files
swissclockclaude
andcommitted
Give the model its own band on phones instead of hiding it behind the text
On a phone held upright the canvas filled the screen behind the lede at 34% opacity, under a veil that was never less than 52% opaque, so the brain peaked at about a sixth of its strength and sat directly behind the text. The whole lower half of the first screen was empty. In portrait below 1080px the canvas is now a band that starts just under the last line of the lede and runs to the bottom of the screen, at full strength, with no veil. Where that band begins depends on the phone's height, the width the text wraps at, and whether the display face has loaded, so main.js measures it rather than the stylesheet guessing, and re-measures on resize and once the fonts are in. On very short screens the band keeps at least 240px and fades in over its top fifth so the fiber emerges from the dark rather than crossing text. As the reader scrolls, the band settles to a quiet trace by a third of a screen down, since content is then passing over it. Delivering light raises it to 85%, and 55% while recovering, so the control is never pressed at something that cannot be seen. The height is set explicitly rather than with top and bottom: a canvas is a replaced element, so height: auto ignores bottom, takes the intrinsic aspect, and the resize observer feeds that back into the drawing buffer. Desktop and landscape are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ec94583 commit 58bc415

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

‎src/main.js‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,59 @@ function trackSection () {
116116

117117
addEventListener('scroll', trackSection, { passive: true })
118118

119+
/* --- the model's presence on a phone ------------------------------------- */
120+
121+
/**
122+
* Held upright on a narrow screen, the model has the lower part of the first
123+
* screen to itself and the stylesheet shows it at whatever --gl-presence says:
124+
* full at the top of the page, and settled to a quiet trace by the time the
125+
* reader has scrolled a third of a screen and content is arriving over it. On
126+
* a wide screen the stylesheet never reads the property, so this is inert.
127+
*
128+
* Rounded to hundredths so a long scroll writes the property eighty times at
129+
* most rather than once per event.
130+
*/
131+
const PRESENCE_FLOOR = 0.2
132+
const PRESENCE_SPAN = 0.35 // of a viewport height
133+
let presence = -1
134+
let viewportHeight = innerHeight
135+
136+
function updatePresence () {
137+
const t = Math.min(1, Math.max(0, scrollY / (viewportHeight * PRESENCE_SPAN)))
138+
const next = Math.round((1 - t * (1 - PRESENCE_FLOOR)) * 100) / 100
139+
if (next === presence) return
140+
presence = next
141+
document.documentElement.style.setProperty('--gl-presence', String(next))
142+
}
143+
144+
addEventListener('scroll', updatePresence, { passive: true })
145+
updatePresence()
146+
147+
/**
148+
* Where the band begins: just under the last line of the lede, measured at the
149+
* top of the page. On a very short screen that would leave the model too little
150+
* room to read as a brain, so the band keeps at least MIN_BAND of height and
151+
* accepts running under the lede's last line, where the stylesheet's fade at
152+
* the top of the band keeps the two apart. A layout read, but only on resize
153+
* and once the fonts land, never in the frame loop.
154+
*/
155+
const MIN_BAND = 240
156+
const ledeText = document.querySelector('#lede .col')
157+
158+
function placeBand () {
159+
if (!ledeText) return
160+
const textBottom = ledeText.getBoundingClientRect().bottom + scrollY
161+
const top = Math.round(Math.max(0, Math.min(textBottom + 8, innerHeight - MIN_BAND)))
162+
const rootStyle = document.documentElement.style
163+
rootStyle.setProperty('--band-top', `${top}px`)
164+
rootStyle.setProperty('--band-h', `${Math.max(MIN_BAND, innerHeight - top)}px`)
165+
}
166+
167+
placeBand()
168+
// The display face is taller than its fallback, so the lede's height is only
169+
// final once it has loaded.
170+
document.fonts?.ready.then(placeBand)
171+
119172
addEventListener('pointermove', (event) => {
120173
scene.setPointer((event.clientX / innerWidth - 0.5) * 2, (event.clientY / innerHeight - 0.5) * 2)
121174
}, { passive: true })
@@ -141,6 +194,11 @@ new ResizeObserver(([entry]) => {
141194
}).observe(canvas)
142195

143196
function resize () {
197+
// iOS changes the viewport height as its toolbars come and go, and that
198+
// moves where "a third of a screen" falls.
199+
viewportHeight = innerHeight
200+
updatePresence()
201+
placeBand()
144202
rules.resize()
145203
trackSection()
146204
}

‎src/styles/layout.css‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,53 @@ footer {
237237
h2 { max-width: none; }
238238
}
239239

240+
/* A phone held upright has no side to put the model on, and behind the text it
241+
can only ever be a ghost: under the veil above it peaked at about a sixth of
242+
its strength. But the lower part of the first screen is empty, so here the
243+
model gets that band to itself at full strength, and the lede moves up to
244+
leave it clear. Landscape keeps the rules above; it is too short for a band. */
245+
@media (max-width: 1080px) and (orientation: portrait) {
246+
/* The band starts where the lede ends. That point moves with the phone's
247+
height, the width the text wraps at, and whether the fonts have arrived,
248+
so main.js measures it into --band-top rather than this guessing at it.
249+
54svh is only the first-paint stand-in. */
250+
#gl {
251+
top: var(--band-top, 54svh);
252+
bottom: auto;
253+
width: 100%;
254+
/* Explicit, not top + bottom: a canvas is a replaced element, so with
255+
height: auto it ignores `bottom` and takes its intrinsic aspect, which
256+
the resize observer then feeds back into the drawing buffer. */
257+
height: var(--band-h, 46svh);
258+
/* Short, because it follows the scroll: a long ease trails the finger. */
259+
transition: opacity 0.3s var(--ease);
260+
/* The fiber runs up well past the brain, and on a short screen (Safari
261+
with its toolbars showing) the top of the band reaches the last lines of
262+
the lede. Fading the band in over its top fifth lets the fiber emerge
263+
from the dark instead of drawing a line through the text; the brain
264+
itself sits below the fade at every phone height. */
265+
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 20%);
266+
mask-image: linear-gradient(to bottom, transparent 0, #000 20%);
267+
}
268+
269+
/* --gl-presence is written by main.js as the reader scrolls: 1 at the top of
270+
the page, settling to a quiet trace once content is coming up over the
271+
band. Delivering light raises it again, so the control is never pressed at
272+
something the reader cannot see. */
273+
#gl.is-loaded { opacity: var(--gl-presence, 1); }
274+
html.is-lit #gl.is-loaded { opacity: max(var(--gl-presence, 1), 0.85); }
275+
html.is-recovering #gl.is-loaded { opacity: max(var(--gl-presence, 1), 0.55); }
276+
277+
/* At the top of the page there is no text over the band to protect, and
278+
further down the canvas opacity does the job the veil used to. */
279+
.veil { display: none; }
280+
281+
#lede {
282+
padding-top: max(108px, 14vh);
283+
padding-top: max(108px, 14svh);
284+
}
285+
}
286+
240287
/* --- narrow ------------------------------------------------------------ */
241288

242289
/* Below this the identity and the six section links cannot share one line

0 commit comments

Comments
 (0)