The roster, the Alumni band, the open-seat node, and the Memories tab's shareable photographs.
people.js + css/11-alumni.css. Nine faces appearing in one frame read as a
layout bug rather than as a disclosure, so the band is animated — and the shape
that takes is worth knowing because it is the standard one:
- The panel is the animated box and the grid inside it is what fades. A
reveal written onto
.alumni-carditself would have to outrank.alumni-card:hover's own transform and would lose to it the moment the pointer crossed a card on the way in. hiddenhad to go:display: nonegives a transition nothing to start from. The closed panel isheight: 0; overflow: hiddeninstead, andinertis what keeps nine buttons out of the tab order without taking them out of layout.autois not a height a transition can reach, so every press measures the grid and writes pixels, andtransitionendhandsautoback. Leaving it on the pixel value would freeze the band at whatever the column count was when it opened, and this grid isauto-fit.- Reduced motion takes a branch in the script too, not only in the
stylesheet: with no transition there is no
transitionend, so the height would never be handed back.
shared/media-lightbox.js, memories/memories.js, the memory- branches in
shared/routes.js and people/people.js, build_memories in
generate_pages.py, and memory_slug in update_people.py.
Pressing a photograph writes /people/memories/<slug>/, and that is a real
page whose og:image is the photograph. Before this, all 32 shared as the
site's own card and resolved to the homepage — for the one kind of content here
whose entire point is the picture, and the kind most often sent into a chat
window rather than typed into a search bar.
Seven things:
- The route is the only one on the site that is three segments deep.
MEMORY_BASEinroutes.js, read before the tab is — otherwise/people/memories/<slug>/resolves to the tab it sits in and a shared link opens the gallery instead of the picture. It nests under the tab because that is where closing the viewer leaves the reader, and because a slug that deep cannot collide with a member folder, soRESERVEDgains nothing. There is deliberately no barememoryroute: it would be a second address for/people/memories/, the exact duplicateCANONICAL_ALIASESexists to undo elsewhere, and here it can simply not be created. - The slug comes from the image's filename, and is the only stable name a
memory has. There is no folder here the way there is for a member or a post,
and the title is hand-editable and repeatable ("Lab lunch"). ASCII-folded for
the reason
update_publications.pyfolds a publication slug — this becomes a directory name — and a collision is reported byload_memories, not repaired, becausemkdir(exist_ok=True)in the generator means it is not an error there: the second page overwrites the first and one photograph goes missing from a set nobody counts by hand. Reported rather than auto-suffixed on purpose — a generated-2is a URL nobody chose, attached to whichever of the two happened to sort second, so it moves if either file is renamed and it would go unnoticed. Two images folding to one slug means two filenames differing only in case or punctuation, which is an oversight inmemories/img/rather than a case to support:2024_Rome.jpgand2024_rome.pngwere the only pair, and the fix was to name the second2024_Rome2.png. A hand-writtenslugin the manifest wins, so a URL that has been shared survives the image being renamed or re-encoded to a different extension. - The viewer knows nothing about routes, and the caller owns both halves.
Which route to write on close depends on where the reader came from — the
gallery writes
people-memories, a profile panel's strip writes the member's own folder — soopenImageLightboxtakes anonClosecallback rather than deciding. It fires for the ✕, the backdrop and Escape alike, which is the whole reason it is one callback here instead of three listeners over there. - The work is split across two modules because only one of them owns the
tab.
people.jsclaims amemory-route for the section, the tab and thelandOnLoadcorrection;memories.jsopens the viewer and nothing else. Clicking the tab button frommemories.js— thejoin.jsidiom — would race the manifest that button's own listener is waiting on.people.jsclaims the route even for a slug that names nothing, so a dead link lands on the gallery rather than on the top of the page. - Escape closes the top layer only, and that needs the capture phase. The
profile panel and the viewer both had a document-level Escape handler, so one
press closed both and left the URL naming a profile nobody was looking at. A
live
is-opentest in the panel's handler cannot fix it — the answer depends on which listener ran first. The viewer's handler is registered withcapture: true, which always runs before every bubbling one, and itpreventDefaults;people.jsbails ondefaultPrevented. Any third layer over this one needs the same pair. memories.jswrites the route it just read, and that is not a claim on it.writeRoute's guard is "already there unless there is a hash", so calling it with the route being applied is a no-op on the path and normalises a legacy#memory-…typed mid-visit to the path it names. deep-link.js does the same tidy-up for the route the page was opened on and cannot do it for one that arrives later.people_namesis written byupdate_people.py, not resolved in the browser.peopleon a memory is a list of member folders — the join key — and the viewer was printing them raw: "2025 · ana-neves, dominique-makowski" under a photograph that is now a page people share. Both manifests are written in the same run, so the two can never disagree, and the alternative is a second fetch racing the first. A folder that names no member is titlecased rather than dropped: guests appear in these photographs.
llms.txt deliberately does not list them. A photograph has no text for a
crawler that only reads, and 32 more lines would dilute the ones that do.
people.js (OPEN_SEAT_STAGES, buildOpenSeat), .mlp-node--seat in
css/10-people-mlp.css, and the [data-join-stage] handler in
information/join.js.
A role with nobody in it used to be skipped, which is the tidy answer and the wrong one: the Postdoc row disappearing does not read as "there is an opening", it reads as a lab with no postdocs and no interest in one. The level renders anyway, holding one node that is nobody — the section's own no-avatar silhouette and "You?" — which leads to that level's own step of the Join rail. Six things:
- Only the levels the lab recruits for get a seat, and each names the
#join-<stage>it leads to.OPEN_SEAT_STAGESis that map, keyed on the manifest's owncategoryvalues. A seat with nowhere to send anyone would be an advert for a vacancy that cannot be applied for, so a role absent from the map is still skipped when empty. - A level with members in it never shows a seat. The row already answers who is here, and an open seat beside four faces reads as a fifth person whose picture failed to load.
- The href is the level's own path (
/join/postdoc/, fromhrefForRoute), not#sec-contact-full. Middle-click, "copy link address" and a crawler then get a real page about the thing the seat is offering. This is the same shape as the zoom's Creations links — see routing.md. - The plain click is caught in
join.js, delegated from the document, and that is not a style choice.data-contact-tab-targetwould have been the obvious attribute to reuse, and it cannot work here: script.js binds those with onequerySelectorAllat startup, and this node is built from a manifest that lands long afterwards. The handler does whatapplyRoutedoes — click the tab button,selectthe level,revealSection— because following the link and pressing the control have to arrive at the same place. It writesjoin-<stage>over thecontact-jointhe tab click just wrote, for the reason documented in routing.md. It is also the general answer for "a control elsewhere on the page that wants a particular Join level", wheredata-contact-tab-target="join"only ever lands on the first one. - The picture is
DEFAULT_AVATAR, the section's existing no-avatar placeholder. A seat-only silhouette was drawn first — same greys, a dashed edge, shorter shoulders — and every difference from this one turned out to be decoration: a person with no picture and a person who is not here yet are the same drawing, and the row's own emptiness is what says which. One asset, and it is one the section already had to keep working. It did get darker in the process (#e7e4da / #c9c4b6 → #dcd8cc / #a9a294, 1.36:1 between the two tones → 1.78:1). That constant had never been seen at size — every member has an avatar, so the open seat is the only place it renders — and at 156px in a row of photographs the old greys read as a blank disc rather than as a person. Both tones had to move together: darkening the silhouette alone makes it a mark on paper instead of a figure on a ground. - The hover ring is the members' own, with
SEAT_KEYWORDS("Fellowships", "Join us", "Info") where a member has research topics —buildKeywordRingunchanged, same place in the node, same CSS. That is what stops the seat being the one node in the roster that does nothing when pointed at. One set for every level rather than a set per level, because Postdoc is the only empty one today; "Fellowships" is the word that would have to change first if the Research Assistant row ever emptied. .mlp-node--seatis two properties, and that is the whole of its styling. An earlier pass gave the seat an italic accent-coloured name, an avatar faded to 0.72 that came up on hover, and a focus ring of its own; all of it was the seat insisting it was a special kind of node when the empty row already says so. What is left undoes the anchor's underline and colour, which is the one real difference — every other node is a<div>. Focus is the browser's default ring, as it is everywhere else on this site. Anything added here should have to justify not being a member node.