Skip to content

Commit 476450b

Browse files
evyatarswclaude
andcommitted
Give the model its own blocks on a phone, in three mockup layouts
The light hold now ignores a lostpointercapture bubbling up from the button's labels, which a touch screen's implicit capture produces. On the model, a touch becomes a hold only after resting for 220 ms, so scrolls never flash the light. Phones get their own framings, a thinner capillary bed and every third Lotto draw, and For fun shows its data by a button instead of hover. ?m=a|b|c picks the layout for the mockup round. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fc2b12b commit 476450b

11 files changed

Lines changed: 482 additions & 145 deletions

File tree

‎index.html‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@
4747
#more-papers { display: none; }
4848
</style>
4949
</noscript>
50+
<!-- Mockup round only: ?m=a|b|c picks the phone layout, and is remembered. -->
51+
<script>
52+
(function () {
53+
var m = (location.search.match(/[?&]m=([abc])/) || [])[1]
54+
try { if (m) localStorage.setItem('mobile-mockup', m); else m = localStorage.getItem('mobile-mockup') } catch (e) {}
55+
document.documentElement.classList.add('m-' + (m || 'a'))
56+
})()
57+
</script>
5058
</head>
5159
<body>
5260
<a class="skip" href="#main">Skip to content</a>

‎src/content/render.js‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ const span = (e) => (e.to ? `${stamp(e.from)} — ${stamp(e.to)}` : stamp(e.from
3838
const eyebrow = (i) => `<span class="tag"><em aria-hidden="true">${n2(i)} — </em>${esc(C.sections[i].label)}</span>`
3939
const head = (i, html) => `<h2 id="${esc(C.sections[i].id)}-title">${html}</h2>`
4040

41+
/**
42+
* Where a phone shows the model: a block of its own in each section that has
43+
* a view, which the canvas moves into while it is on screen. Above 1080px the
44+
* model sits beside the column instead and these stay out of the layout.
45+
* `at` is where in the section the block stands (mockup round: each variant
46+
* shows one of them). The note carries a line about what the view shows.
47+
*/
48+
const fig = (i, at) => `<div class="fig fig-${at}" data-frame="${i}" aria-hidden="true"><span class="fig-note"></span></div>`
49+
4150
/**
4251
* Structured data. The page's whole job in search is to be the answer to this
4352
* person's name, and that is an entity question rather than a keyword one:
@@ -123,14 +132,17 @@ function lede () {
123132
<h1 id="lede-title"><span>${esc(given)}</span> <span class="b">${esc(family)}</span></h1>
124133
<p class="lede-p">${C.person.thesis}</p>
125134
<p class="sub">${aff}</p>
126-
</div>`
135+
</div>
136+
${fig(0, 'lede')}`
127137
}
128138

129139
function now (i) {
130140
return `
141+
${fig(i, 'top')}
131142
<div class="col">
132143
${eyebrow(i)}
133144
${head(i, C.now.heading)}
145+
${fig(i, 'in')}
134146
${C.now.paragraphs.map((p) => `<p class="body">${p}</p>`).join('\n ')}
135147
<p class="note">${C.now.note}</p>
136148
</div>`
@@ -150,9 +162,11 @@ function entry (e) {
150162

151163
function research (i) {
152164
return `
165+
${fig(i, 'top')}
153166
<div class="col">
154167
${eyebrow(i)}
155168
${head(i, C.research.heading)}
169+
${fig(i, 'in')}
156170
<p class="body">${C.research.intro}</p>
157171
<ul class="entries" role="list">${C.research.entries.map(entry).join('')}
158172
</ul>
@@ -223,9 +237,11 @@ function papers (i) {
223237

224238
function fun (i) {
225239
return `
240+
${fig(i, 'top')}
226241
<div class="col">
227242
${eyebrow(i)}
228243
${head(i, C.fun.heading)}
244+
${fig(i, 'in')}
229245
<ul class="entries" role="list">${C.projects.map(project).join('')}
230246
</ul>
231247
</div>
@@ -241,9 +257,11 @@ function contact (i) {
241257
.map((l) => `<li><a href="${esc(l.href)}"${offsite(l.href)}>${esc(l.label)}</a></li>`)
242258
.join('')
243259
return `
260+
${fig(i, 'top')}
244261
<div class="col">
245262
${eyebrow(i)}
246263
${head(i, esc(C.contact.heading))}
264+
${fig(i, 'in')}
247265
<p class="body">${esc(C.contact.body)}</p>
248266
<ul class="links" role="list">${links}</ul>
249267
</div>`

‎src/main.js‎

Lines changed: 140 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,16 @@ let scene = {
4747
}
4848

4949
import('./scene/index.js').then(({ createScene }) => {
50-
scene = createScene({ canvas, cool: COLOURS.resting, warm: COLOURS.lit, cord: token('--cord'), ink: token('--ink'), dim: token('--ink-4'), label: token('--ink-3'), ground: token('--bg'), onFit: placeCaption })
50+
scene = createScene({ canvas, cool: COLOURS.resting, warm: COLOURS.lit, cord: token('--cord'), ink: token('--ink'), dim: token('--ink-4'), label: token('--ink-3'), ground: token('--bg'), onFit: placeCaption, light: matchMedia('(hover: none) and (pointer: coarse)').matches })
5151
// Replay the viewport and the section the reader is already on; a scene that
52-
// starts at frame 0 with a square aspect would visibly snap into place.
52+
// starts at frame 0 with a square aspect would visibly snap into place. On a
53+
// phone that is the block the canvas already stands in.
5354
sizeScene()
54-
scene.setFrame(active)
55+
if (phone.matches && host) {
56+
const fig = host
57+
host = null
58+
hostFig(fig)
59+
} else scene.setFrame(active)
5560
scene.setView(shownViz)
5661
measureFun()
5762
})
@@ -113,64 +118,98 @@ function trackSection () {
113118
if (i === active) link.setAttribute('aria-current', 'true')
114119
else link.removeAttribute('aria-current')
115120
})
116-
scene.setFrame(active)
121+
if (!phone.matches) scene.setFrame(active)
117122
syncCaption()
118123
}
119124

120125
addEventListener('scroll', trackSection, { passive: true })
121126

122-
/* --- the model's presence on a phone ------------------------------------- */
127+
/* --- the model's place on a phone --------------------------------------- */
123128

124129
/**
125-
* Held upright on a narrow screen, the model has the lower part of the first
126-
* screen to itself and the stylesheet shows it at whatever --gl-presence says:
127-
* full at the top of the page, and settled to a quiet trace by the time the
128-
* reader has scrolled a third of a screen and content is arriving over it. On
129-
* a wide screen the stylesheet never reads the property, so this is inert.
130-
*
131-
* Rounded to hundredths so a long scroll writes the property eighty times at
132-
* most rather than once per event.
130+
* Below the layout change there is no side for the model to stand on, so each
131+
* section with a view has a block of its own (.fig) and the one canvas moves
132+
* into whichever block is coming on screen. Moving the element keeps its GL
133+
* context, and inside a block it scrolls with the page natively. The framing
134+
* follows the block rather than the section the reader is in, and nothing is
135+
* drawn while no block is on screen: on a phone that is most of the page.
133136
*/
134-
const PRESENCE_FLOOR = 0.2
135-
const PRESENCE_SPAN = 0.35 // of a viewport height
136-
let presence = -1
137-
let viewportHeight = innerHeight
138-
139-
function updatePresence () {
140-
const t = Math.min(1, Math.max(0, scrollY / (viewportHeight * PRESENCE_SPAN)))
141-
const next = Math.round((1 - t * (1 - PRESENCE_FLOOR)) * 100) / 100
142-
if (next === presence) return
143-
presence = next
144-
document.documentElement.style.setProperty('--gl-presence', String(next))
145-
}
137+
const phone = matchMedia('(max-width: 1080px)')
138+
const figs = [...document.querySelectorAll('.fig')]
139+
const canvasHome = { parent: canvas.parentNode, next: canvas.nextSibling }
140+
const nearness = new Map()
141+
const visible = new Set()
142+
let host = null
146143

147-
addEventListener('scroll', updatePresence, { passive: true })
148-
updatePresence()
144+
/** Mockup round: which of the three layouts this page is showing. */
145+
const variant = (document.documentElement.className.match(/\bm-([abc])\b/) || [])[1] || 'a'
149146

150147
/**
151-
* Where the band begins: just under the last line of the lede, measured at the
152-
* top of the page. On a very short screen that would leave the model too little
153-
* room to read as a brain, so the band keeps at least MIN_BAND of height and
154-
* accepts running under the lede's last line, where the stylesheet's fade at
155-
* the top of the band keeps the two apart. A layout read, but only on resize
156-
* and once the fonts land, never in the frame loop.
148+
* How each layout wants the view placed in its block, as a vertical shift of
149+
* the image in normalised coordinates: an opener keeps its heading's room
150+
* clear at the foot of the block.
157151
*/
158-
const MIN_BAND = 240
159-
const ledeText = document.querySelector('#lede .col')
160-
161-
function placeBand () {
162-
if (!ledeText) return
163-
const textBottom = ledeText.getBoundingClientRect().bottom + scrollY
164-
const top = Math.round(Math.max(0, Math.min(textBottom + 8, innerHeight - MIN_BAND)))
165-
const rootStyle = document.documentElement.style
166-
rootStyle.setProperty('--band-top', `${top}px`)
167-
rootStyle.setProperty('--band-h', `${Math.max(MIN_BAND, innerHeight - top)}px`)
152+
const LENS = { a: 0, b: 0.3, c: 0 }
153+
154+
function hostFig (fig) {
155+
if (fig === host) return
156+
host = fig
157+
fig.prepend(canvas)
158+
hostNote = fig.querySelector('.fig-note')
159+
scene.setLens?.({ shift: Number(fig.dataset.frame) === FUN ? 0 : LENS[variant], phone: true })
160+
scene.setFrame(Number(fig.dataset.frame))
161+
measureFun()
162+
}
163+
164+
function pickFig () {
165+
if (!phone.matches) return
166+
let best = null
167+
let bestRatio = 0
168+
for (const fig of figs) {
169+
const ratio = nearness.get(fig) || 0
170+
if (ratio > bestRatio) { best = fig; bestRatio = ratio }
171+
}
172+
if (best) hostFig(best)
168173
}
169174

170-
placeBand()
171-
// The display face is taller than its fallback, so the lede's height is only
172-
// final once it has loaded.
173-
document.fonts?.ready.then(() => { placeBand(); measureFun() })
175+
// Chosen a little ahead, over a margin below the screen, so the framing has
176+
// settled by the time the block scrolls into view…
177+
const ahead = new IntersectionObserver((entries) => {
178+
for (const entry of entries) nearness.set(entry.target, entry.isIntersecting ? entry.intersectionRatio || 0.001 : 0)
179+
pickFig()
180+
}, { rootMargin: '0px 0px 60% 0px', threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1] })
181+
// …and drawn only while it is actually on screen.
182+
const seen = new IntersectionObserver((entries) => {
183+
for (const entry of entries) {
184+
if (entry.isIntersecting) visible.add(entry.target)
185+
else visible.delete(entry.target)
186+
}
187+
})
188+
for (const fig of figs) { ahead.observe(fig); seen.observe(fig) }
189+
190+
phone.addEventListener('change', () => {
191+
if (phone.matches) {
192+
host = null
193+
pickFig()
194+
return
195+
}
196+
canvasHome.parent.insertBefore(canvas, canvasHome.next)
197+
host = null
198+
scene.setLens?.({ shift: 0 })
199+
scene.setFrame(active)
200+
measureFun()
201+
})
202+
203+
let hostNote = null
204+
205+
// Mockup round only: the chooser between the three phone layouts.
206+
{
207+
const chooser = document.createElement('nav')
208+
chooser.className = 'mock-switch'
209+
chooser.setAttribute('aria-label', 'Mockup layouts')
210+
chooser.innerHTML = ['a', 'b', 'c'].map((m) => `<a href="?m=${m}"${m === variant ? ' class="on"' : ''}>${m}</a>`).join('')
211+
document.body.append(chooser)
212+
}
174213

175214
addEventListener('pointermove', (event) => {
176215
scene.setPointer((event.clientX / innerWidth - 0.5) * 2, (event.clientY / innerHeight - 0.5) * 2)
@@ -200,11 +239,8 @@ const footer = document.querySelector('footer')
200239

201240
function resize () {
202241
if (footer) document.documentElement.style.setProperty('--footer-h', `${Math.ceil(footer.getBoundingClientRect().height)}px`)
203-
// iOS changes the viewport height as its toolbars come and go, and that
204-
// moves where "a third of a screen" falls.
205-
viewportHeight = innerHeight
206-
updatePresence()
207-
placeBand()
242+
const rail = document.querySelector('.rail')
243+
if (rail) document.documentElement.style.setProperty('--bar-h', `${Math.ceil(rail.getBoundingClientRect().height)}px`)
208244
measureFun()
209245
rules.resize()
210246
trackSection()
@@ -276,22 +312,41 @@ function syncCaption () {
276312
document.documentElement.classList.toggle('caption-on', Boolean(pointedRow) && active === FUN)
277313
}
278314

315+
// Pointing is for a mouse; a finger's taps fire the same enter and leave, and
316+
// would bring a view up only to drop it again as the finger lifts.
279317
for (const row of projectRows) {
280-
row.addEventListener('pointerenter', () => point(row))
281-
row.addEventListener('pointerleave', () => point(null))
282-
row.addEventListener('focusin', () => point(row))
283-
row.addEventListener('focusout', () => point(null))
318+
row.addEventListener('pointerenter', (event) => { if (event.pointerType !== 'touch') point(row) })
319+
row.addEventListener('pointerleave', (event) => { if (event.pointerType !== 'touch') point(null) })
320+
row.addEventListener('focusin', () => { if (!phone.matches) point(row) })
321+
row.addEventListener('focusout', () => { if (!phone.matches) point(null) })
284322
}
285323
showViz(null)
286324

287-
if (vizRows.length && matchMedia('(hover: none)').matches) {
288-
const turns = [null, ...vizRows.map((row) => row.dataset.viz)]
289-
let turn = 0
290-
setInterval(() => {
291-
if (active !== FUN || document.hidden) return
292-
turn = (turn + 1) % turns.length
293-
showViz(turns[turn])
294-
}, 8000)
325+
/**
326+
* On a phone there is no pointing, so a project with data behind it carries a
327+
* button that brings its view up in the section's block, and its title stays
328+
* the link it always was. Tapping it again, or another, puts the view back.
329+
*/
330+
let picked = null
331+
const peeks = vizRows.map((row) => {
332+
const button = document.createElement('button')
333+
button.type = 'button'
334+
button.className = 'peek'
335+
button.setAttribute('aria-pressed', 'false')
336+
button.textContent = 'Show the data'
337+
button.addEventListener('click', () => pick(picked === row ? null : row))
338+
row.querySelector('h3').after(button)
339+
return button
340+
})
341+
342+
function pick (row) {
343+
picked = row
344+
showViz(row?.dataset.viz ?? null)
345+
shownLive = null
346+
vizRows.forEach((r, i) => {
347+
peeks[i].setAttribute('aria-pressed', String(r === row))
348+
peeks[i].textContent = r === row ? 'Showing, above' : 'Show the data'
349+
})
295350
}
296351

297352
/**
@@ -302,13 +357,16 @@ if (vizRows.length && matchMedia('(hover: none)').matches) {
302357
* and once the fonts have landed.
303358
*/
304359
const funSection = document.getElementById('fun')
360+
// The section's last row only finds its height once the display face is in.
361+
document.fonts?.ready.then(() => measureFun())
305362

306363
function measureFun () {
307364
if (!funSection || !projectRows.length) return
308-
// Below the layout change the model sits behind the text rather than beside
309-
// it, and the views keep a plain upper-middle band of the canvas.
310-
if (innerWidth <= 1080) {
311-
scene.setFitBox?.({ top: 0.55, bottom: -0.35 })
365+
// On a phone the views fill their own block, inside a margin, and leave
366+
// the foot of an opener to its heading.
367+
if (phone.matches) {
368+
const foot = variant === 'b' ? -0.05 : -0.74
369+
scene.setFitBox?.({ top: 0.7, bottom: foot, left: -0.84, right: 0.84 })
312370
return
313371
}
314372
const origin = funSection.getBoundingClientRect().top
@@ -332,6 +390,9 @@ const CONTACT = sections.findIndex((section) => section.id === 'contact')
332390
let dragging = null
333391

334392
function overModel (event) {
393+
// On a phone the model has its own block, so a finger can turn it there:
394+
// the block takes sideways moves and leaves upright ones to the page.
395+
if (phone.matches) return Number(host?.dataset.frame) === CONTACT && Boolean(host?.contains(event.target))
335396
if (active !== CONTACT || event.pointerType === 'touch') return false
336397
if (event.target.closest?.('a, button, input, textarea, label, .col, footer')) return false
337398
const box = canvas.getBoundingClientRect()
@@ -422,16 +483,24 @@ function tick (now) {
422483
frameState.lit = stim.held
423484

424485
scene.update(dt, frameState)
425-
scene.render()
426-
if (pointedRow?.dataset.viz && active === FUN) {
486+
// On a phone the canvas is only ever inside a block, so with its block off
487+
// screen there is nothing to draw.
488+
if (!phone.matches || visible.has(host)) scene.render()
489+
// What the project's view shows right now: in the caption beside it, or on
490+
// a phone at the head of its block.
491+
const onPhone = phone.matches
492+
const liveLine = onPhone ? (Number(host?.dataset.frame) === FUN ? hostNote : null) : captionLive
493+
const describing = onPhone ? Boolean(picked) : Boolean(pointedRow?.dataset.viz) && active === FUN
494+
if (describing && liveLine) {
427495
const live = scene.live ?? ''
428496
if (live !== shownLive) {
429497
shownLive = live
430-
captionLive.textContent = live
498+
liveLine.textContent = live
431499
}
432500
} else if (shownLive !== '') {
433501
shownLive = ''
434502
if (captionLive) captionLive.textContent = ''
503+
if (hostNote) hostNote.textContent = ''
435504
}
436505
rules.draw(signal, COLOURS[stim.state], RULE)
437506
}

0 commit comments

Comments
 (0)