Skip to content

Commit 1566324

Browse files
evyatarswclaude
andcommitted
Go with the stage on phones, and let For fun follow the scroll
Evyatar chose layout C. The other two layouts, the chooser and the Show the data buttons are gone: the project row passing just under the stage now chooses its view, and the landscape holds above the first row. Links bring a section in below the bar so its stage never covers the heading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 476450b commit 1566324

8 files changed

Lines changed: 93 additions & 193 deletions

File tree

‎SPEC.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ These came directly from Evyatar and are not open to reinterpretation.
2626
- **Side projects stay quiet.** They are hobbies, in their own section ("For fun",
2727
06): one line each, no screenshots, no cards, no metrics. The two with data
2828
behind them (sikui, NextPredictor) lend that real data to the model on hover;
29-
their descriptions then sit in a caption under it, in one fixed place.
29+
their descriptions then sit in a caption under it, in one fixed place. On a
30+
phone there is no hover: the stage shows each project's view as its row
31+
scrolls up under it.
3032
- **No invented facts.** Titles, dates, journals, mechanisms and wavelengths come
3133
from `src/content/data.js`, which was built from his CV and PubMed. If you think
3234
something is wrong, flag it, do not silently change it.
@@ -137,7 +139,10 @@ A change is finished when all of these hold.
137139
- Works at 390px, 768px, 1280px and 1920px wide.
138140
- No horizontal page scroll at any width.
139141
- At least a 16px gutter at every width.
140-
- The rail collapses to a top bar below 1080px; the model drops behind the text.
142+
- The rail collapses to a top bar below 1080px. The model never goes behind the
143+
text there: it takes the lower part of the first screen, then each section
144+
with a view (Now, Research, For fun, Connect) opens on a stage held under the
145+
bar while its text scrolls beneath, and nothing is drawn between stages.
141146

142147
**Accessibility**
143148
- Every control reachable and operable by keyboard, with a visible focus ring.

‎docs/mobile-handoff.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Handoff: the mobile overhaul
22

3+
> **Status, 2 October 2026 (later the same day).** On branch `mobile-overhaul`,
4+
> not pushed. Of three mockups (figures, openers, stage) Evyatar chose **C, the
5+
> stage**: each section with a view opens on a block held under the bar
6+
> (`position: sticky`, 44svh) while its text scrolls beneath; the one canvas
7+
> moves into whichever block is on screen and nothing is drawn between them.
8+
> For fun follows the scroll: the row passing just under the stage chooses the
9+
> view (his request, instead of a "Show the data" button). Phone framings are
10+
> `phoneFrames` in `src/scene/frames.js`. Phones also draw a third of the
11+
> capillaries and every third Lotto draw. The light fix (a
12+
> `lostpointercapture` bubbling up from the button's labels) is unconfirmed on
13+
> iOS: the Simulator runtime still has to be installed. Desktop was checked
14+
> pixel-identical to `main`. Everything below is the original brief.
15+
316
Written 2 October 2026, at the end of the session that built the Research
417
vasculature, the For fun section and the Connect tracts. **The desktop site is
518
approved and live. The phone experience is not, and needs a proper redesign,

‎index.html‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,6 @@
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>
5850
</head>
5951
<body>
6052
<a class="skip" href="#main">Skip to content</a>

‎src/content/render.js‎

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,12 @@ const eyebrow = (i) => `<span class="tag"><em aria-hidden="true">${n2(i)} — </
3939
const head = (i, html) => `<h2 id="${esc(C.sections[i].id)}-title">${html}</h2>`
4040

4141
/**
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.
42+
* Where a phone shows the model: a stage of its own at the head of each
43+
* section that has a view, which the canvas moves into while it is on screen.
44+
* Above 1080px the model sits beside the column instead and these stay out of
45+
* the layout. The note carries a line about what the view is showing.
4746
*/
48-
const fig = (i, at) => `<div class="fig fig-${at}" data-frame="${i}" aria-hidden="true"><span class="fig-note"></span></div>`
47+
const fig = (i) => `<div class="fig" data-frame="${i}" aria-hidden="true"><span class="fig-note"></span></div>`
4948

5049
/**
5150
* Structured data. The page's whole job in search is to be the answer to this
@@ -133,16 +132,15 @@ function lede () {
133132
<p class="lede-p">${C.person.thesis}</p>
134133
<p class="sub">${aff}</p>
135134
</div>
136-
${fig(0, 'lede')}`
135+
${fig(0)}`
137136
}
138137

139138
function now (i) {
140139
return `
141-
${fig(i, 'top')}
140+
${fig(i)}
142141
<div class="col">
143142
${eyebrow(i)}
144143
${head(i, C.now.heading)}
145-
${fig(i, 'in')}
146144
${C.now.paragraphs.map((p) => `<p class="body">${p}</p>`).join('\n ')}
147145
<p class="note">${C.now.note}</p>
148146
</div>`
@@ -162,11 +160,10 @@ function entry (e) {
162160

163161
function research (i) {
164162
return `
165-
${fig(i, 'top')}
163+
${fig(i)}
166164
<div class="col">
167165
${eyebrow(i)}
168166
${head(i, C.research.heading)}
169-
${fig(i, 'in')}
170167
<p class="body">${C.research.intro}</p>
171168
<ul class="entries" role="list">${C.research.entries.map(entry).join('')}
172169
</ul>
@@ -237,11 +234,10 @@ function papers (i) {
237234

238235
function fun (i) {
239236
return `
240-
${fig(i, 'top')}
237+
${fig(i)}
241238
<div class="col">
242239
${eyebrow(i)}
243240
${head(i, C.fun.heading)}
244-
${fig(i, 'in')}
245241
<ul class="entries" role="list">${C.projects.map(project).join('')}
246242
</ul>
247243
</div>
@@ -257,11 +253,10 @@ function contact (i) {
257253
.map((l) => `<li><a href="${esc(l.href)}"${offsite(l.href)}>${esc(l.label)}</a></li>`)
258254
.join('')
259255
return `
260-
${fig(i, 'top')}
256+
${fig(i)}
261257
<div class="col">
262258
${eyebrow(i)}
263259
${head(i, esc(C.contact.heading))}
264-
${fig(i, 'in')}
265260
<p class="body">${esc(C.contact.body)}</p>
266261
<ul class="links" role="list">${links}</ul>
267262
</div>`

‎src/main.js‎

Lines changed: 42 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ addEventListener('scroll', trackSection, { passive: true })
128128

129129
/**
130130
* 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
131+
* section with a view has a stage of its own (.fig) and the one canvas moves
132132
* into whichever block is coming on screen. Moving the element keeps its GL
133133
* context, and inside a block it scrolls with the page natively. The framing
134134
* follows the block rather than the section the reader is in, and nothing is
@@ -141,22 +141,12 @@ const nearness = new Map()
141141
const visible = new Set()
142142
let host = null
143143

144-
/** Mockup round: which of the three layouts this page is showing. */
145-
const variant = (document.documentElement.className.match(/\bm-([abc])\b/) || [])[1] || 'a'
146-
147-
/**
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.
151-
*/
152-
const LENS = { a: 0, b: 0.3, c: 0 }
153-
154144
function hostFig (fig) {
155145
if (fig === host) return
156146
host = fig
157147
fig.prepend(canvas)
158148
hostNote = fig.querySelector('.fig-note')
159-
scene.setLens?.({ shift: Number(fig.dataset.frame) === FUN ? 0 : LENS[variant], phone: true })
149+
scene.setPhone?.(true)
160150
scene.setFrame(Number(fig.dataset.frame))
161151
measureFun()
162152
}
@@ -195,22 +185,14 @@ phone.addEventListener('change', () => {
195185
}
196186
canvasHome.parent.insertBefore(canvas, canvasHome.next)
197187
host = null
198-
scene.setLens?.({ shift: 0 })
188+
read(null)
189+
scene.setPhone?.(false)
199190
scene.setFrame(active)
200191
measureFun()
201192
})
202193

203194
let hostNote = null
204195

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-
}
213-
214196
addEventListener('pointermove', (event) => {
215197
scene.setPointer((event.clientX / innerWidth - 0.5) * 2, (event.clientY / innerHeight - 0.5) * 2)
216198
}, { passive: true })
@@ -242,6 +224,7 @@ function resize () {
242224
const rail = document.querySelector('.rail')
243225
if (rail) document.documentElement.style.setProperty('--bar-h', `${Math.ceil(rail.getBoundingClientRect().height)}px`)
244226
measureFun()
227+
watchReading()
245228
rules.resize()
246229
trackSection()
247230
}
@@ -253,9 +236,8 @@ addEventListener('orientationchange', resize)
253236
/**
254237
* At rest the section's model is its general view. Pointing at a project, or
255238
* tabbing to its link, puts that project's description in the caption under
256-
* the model, and the two with data behind them bring up their own views. With
257-
* no hover to point with, the view takes turns on its own while the section is
258-
* on screen, so a phone still sees both.
239+
* the model, and the two with data behind them bring up their own views. A
240+
* phone has no pointing, and reads the list as it scrolls instead (below).
259241
*/
260242
const projectRows = [...document.querySelectorAll('.project')]
261243
const vizRows = projectRows.filter((row) => row.dataset.viz)
@@ -323,30 +305,41 @@ for (const row of projectRows) {
323305
showViz(null)
324306

325307
/**
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.
308+
* On a phone there is no pointing, but the section's stage holds at the top
309+
* of the screen while its list scrolls up beneath it. So the row passing just
310+
* under the stage is the one being read, and the stage shows its view: a
311+
* project with data brings its own up, any other puts the landscape back. In
312+
* the gaps between rows the last one read keeps the stage, and above the
313+
* first, while the heading is still being read, the landscape has it.
329314
*/
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-
})
315+
let reading = null
316+
let readingWatch = null
341317

342-
function pick (row) {
343-
picked = row
344-
showViz(row?.dataset.viz ?? null)
318+
function read (row) {
319+
if (row === reading) return
320+
reading = row
345321
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-
})
322+
showViz(row?.dataset.viz ?? null)
323+
}
324+
325+
/**
326+
* The reading line, a pixel tall, a little below the stage's foot. Its place
327+
* depends on the bar and the screen's height, so it is drawn again on resize.
328+
*/
329+
function watchReading () {
330+
readingWatch?.disconnect()
331+
if (!phone.matches || !projectRows.length) return
332+
const rail = document.querySelector('.rail')
333+
const stage = funSection?.querySelector('.fig')
334+
const foot = (rail?.getBoundingClientRect().height ?? 0) + (stage?.getBoundingClientRect().height ?? 0) + 16
335+
const line = Math.min(innerHeight - 2, Math.round(foot))
336+
readingWatch = new IntersectionObserver((entries) => {
337+
for (const entry of entries) {
338+
if (entry.isIntersecting) read(entry.target)
339+
else if (entry.target === projectRows[0] && entry.boundingClientRect.top > line) read(null)
340+
}
341+
}, { rootMargin: `${-line}px 0px ${-(innerHeight - line - 1)}px 0px` })
342+
for (const row of projectRows) readingWatch.observe(row)
350343
}
351344

352345
/**
@@ -362,11 +355,9 @@ document.fonts?.ready.then(() => measureFun())
362355

363356
function measureFun () {
364357
if (!funSection || !projectRows.length) return
365-
// On a phone the views fill their own block, inside a margin, and leave
366-
// the foot of an opener to its heading.
358+
// On a phone the views fill their own stage, inside a margin.
367359
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 })
360+
scene.setFitBox?.({ top: 0.7, bottom: -0.74, left: -0.84, right: 0.84 })
370361
return
371362
}
372363
const origin = funSection.getBoundingClientRect().top
@@ -490,7 +481,7 @@ function tick (now) {
490481
// a phone at the head of its block.
491482
const onPhone = phone.matches
492483
const liveLine = onPhone ? (Number(host?.dataset.frame) === FUN ? hostNote : null) : captionLive
493-
const describing = onPhone ? Boolean(picked) : Boolean(pointedRow?.dataset.viz) && active === FUN
484+
const describing = onPhone ? Boolean(reading?.dataset.viz) : Boolean(pointedRow?.dataset.viz) && active === FUN
494485
if (describing && liveLine) {
495486
const live = scene.live ?? ''
496487
if (live !== shownLive) {

‎src/scene/index.js‎

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -272,11 +272,9 @@ export function createScene ({ canvas, cool, warm, cord, ink, dim, label, ground
272272
brain.rotation.set(0.06, wideFrames[0].spin, 0)
273273

274274
// --- state ------------------------------------------------------------
275-
/** The framings in use: the wide ones, or a phone's (see setLens). */
275+
/** The framings in use: the wide ones, or a phone's (see setPhone). */
276276
let frames = wideFrames
277277
let frame = 0
278-
/** Vertical shift of the image, in normalised coordinates (see setLens). */
279-
let lensShift = 0
280278
let hotMix = frames[0].hot
281279
const pointer = { x: 0, y: 0 }
282280
const smoothed = { x: 0, y: 0 }
@@ -382,14 +380,6 @@ export function createScene ({ canvas, cool, warm, cord, ink, dim, label, ground
382380
}).catch((error) => console.warn(`Vessel geometry unavailable: ${error.message}`))
383381
}
384382

385-
function project () {
386-
camera.updateProjectionMatrix()
387-
// The principal point's offset lives in the third column; a positive
388-
// shift there moves the whole image down, so this moves it up.
389-
camera.projectionMatrix.elements[9] -= lensShift
390-
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert()
391-
}
392-
393383
return {
394384
setFrame (index) {
395385
frame = Math.max(0, Math.min(frames.length - 1, index))
@@ -440,21 +430,14 @@ export function createScene ({ canvas, cool, warm, cord, ink, dim, label, ground
440430
// a narrow window shows more sky rather than less brain.
441431
const widen = aspect < REFERENCE_ASPECT ? REFERENCE_ASPECT / aspect : 1
442432
camera.fov = 2 * Math.atan(Math.tan((BASE_FOV * Math.PI) / 360) * widen) * (180 / Math.PI)
443-
project()
433+
camera.updateProjectionMatrix()
444434
shared.uPixelRatio.value = renderer.getPixelRatio()
445435
fitFun()
446436
},
447437

448-
/**
449-
* Moves the image up by `shift` (normalised device units) without turning
450-
* the camera, the way a shifted lens does: on a phone an opener keeps the
451-
* foot of its block for the heading set over it.
452-
*/
453-
setLens ({ shift = 0, phone = false } = {}) {
454-
frames = phone ? phoneFrames : wideFrames
455-
if (shift === lensShift) { fitFun(); return }
456-
lensShift = shift
457-
project()
438+
/** Uses the framings composed for a phone's full-width stage, or not. */
439+
setPhone (on) {
440+
frames = on ? phoneFrames : wideFrames
458441
fitFun()
459442
},
460443

‎src/styles/components.css‎

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -358,47 +358,3 @@ html.is-recovering .stim .lamp::after { background: var(--fade); opacity: 0.55;
358358
footer { padding-bottom: calc(var(--lead-in) + 78px + env(safe-area-inset-bottom, 0px)); }
359359
}
360360

361-
/* On a phone, the projects with data behind them show it by a button of
362-
their own; with a pointer, hovering the row does the same. */
363-
.peek { display: none; }
364-
@media (max-width: 1080px) {
365-
.peek {
366-
display: inline-block;
367-
margin-top: 0.7em;
368-
padding: 0.55em 0.8em;
369-
border: 1px solid var(--rule);
370-
border-radius: 2px;
371-
font: 400 var(--t-label)/1.2 var(--mono);
372-
letter-spacing: var(--track-meta);
373-
text-transform: uppercase;
374-
background: none;
375-
color: var(--ink-2);
376-
-webkit-tap-highlight-color: transparent;
377-
}
378-
.peek[aria-pressed="true"] { color: var(--accent); border-color: currentColor; }
379-
}
380-
381-
/* Mockup round only: the a / b / c chooser for the phone layouts. */
382-
.mock-switch { display: none; }
383-
@media (max-width: 1080px) {
384-
.mock-switch {
385-
position: fixed;
386-
right: 0;
387-
top: 42%;
388-
z-index: 6;
389-
display: flex;
390-
flex-direction: column;
391-
gap: 4px;
392-
}
393-
.mock-switch a {
394-
display: grid;
395-
place-items: center;
396-
width: 26px; height: 30px;
397-
border: 1px solid var(--rule);
398-
background: rgba(13, 17, 22, 0.72);
399-
font: 400 var(--t-label)/1 var(--mono);
400-
text-transform: uppercase;
401-
color: var(--ink-3);
402-
}
403-
.mock-switch a.on { color: var(--ink); border-color: var(--ink-3); }
404-
}

0 commit comments

Comments
 (0)