Personal site for Kasper Rømer Grøntved — publications, CV, projects, and a browser-native slide deck engine for talks.
Plain static HTML. No Jekyll, no Ruby, no npm, no build step. GitHub Actions uploads the repo as-is to Pages.
index.html landing page (masthead, fact bar, then a board of tiles: Ask this site, the featured lecture, the four latest talks and posts)
publications.html full publication list with abstracts
cv.html CV in HTML · assets/cv/cv.pdf is the downloadable version
404.html
knowledge/index.html the talks + writing index (featured deck, posts, decks) — the blog index merged into it
knowledge/<slug>/index.html one deck = one HTML file
knowledge/_template/ copy this to start a new deck
search/index.html SPLADE site search (see below)
search/splade.js retrieval engine: WordPiece + sparse dot product
search/autocomplete.js the search box and results dropdown, shared by both pages
search/search-ui.js /search/ only: the breakdown panels
search/home-search.js the front page band: the widget, and the generated answer
search/search.css styles for the box, the dropdown and the panels
search/index.json … the built index (generated — CI rebuilds it)
search/corpus.json the same passages without their vectors (generated)
content/publications/ full paper text, read at build time (public, like everything here)
worker/ the Cloudflare Worker behind "Answer this" (see below)
tools/build_search_index.py extracts the corpus and encodes it
site/site.css shared design system for the site pages
deck/deck.css slide styles, same tokens as site.css
deck/deck.js slide runtime (~200 lines, vanilla JS)
assets/img/ web-optimized images
assets/cv/cv.pdf downloadable CV (generated — see below)
assets/cv/cv-print.html the print source the PDF is rendered from
blog/index.html redirect stub → /knowledge/ (the posts are listed there)
blog/<slug>/index.html one post = one HTML file, hand-written; inline SVG for figures
blog/blog.css article styles (prose, figures, tables, the receipt), on top of site.css
blog/*/ posts/ hop-database/ redirect stubs for the retired Jekyll blog (see below)
publications/ cv/ redirect stubs for the old Jekyll nav URLs
assets/favicon.svg the tab icon: a white K over a diffuse aurora wash on the page canvas; follows the OS theme
favicon.ico the same mark as 16/32/48 px PNGs, for browsers that skip SVG icons (Safari)
sitemap.xml robots.txt CNAME .nojekyll
.nojekyll matters: without it GitHub Pages runs Jekyll and ignores files starting
with _, which would break knowledge/_template/.
There is no navigation bar. The fact bar on the front page carries the outward links (GitHub,
Scholar, LinkedIn, the CV as PDF), the latest tile links to /knowledge/, and every other page
carries a slim, non-sticky header with the brand and a single Front page → link. There is no
index of pages: publications.html, cv.html and search/ are reached from the pages that
cite them (the CV, the Knowledge page, the SPLADE post, the 404) rather than from the front page.
projects.html is retired and deliberately not in the list.
The posts live under /blog/<slug>/, one hand-written index.html per post, no generator, but
the blog has no index page of its own: /blog/ redirects to /knowledge/, which lists the talks
and the posts together (a Writing section between Featured and Talks & decks). Figures are
inline SVG that use the colour tokens (fill="var(--accent)"), so a palette change in site.css
reaches them too. To add a post, copy an existing one, add a row to the Writing list in
knowledge/index.html, and the URL to sitemap.xml. The first post,
blog/inference-free-splade/, is the write-up of the site search below; the numbers in it are read
off the built index, so if the corpus changes materially the post's figures are the thing to
re-check.
The two old Jekyll post directories under blog/ (swarm-simulator, hop-database) are
redirect stubs, not posts.
The site used to be Jekyll with a blog under the permalink pattern /:categories/:title/.
The eight posts are gone and two pages changed address, but the old URLs are still linked to
and indexed, so each one keeps a tiny stub at its old path: blog/, posts/,
hop-database/, publications/ and cv/. Every stub is a single
index.html with a meta refresh, location.replace(), a canonical link to the target,
robots: noindex, and a visible fallback link for anyone with JS off. An HTML comment at the
top of each names the page or post it replaces.
| Old URL | Now goes to |
|---|---|
/posts/ (old blog index) | / |
/blog/ (blog index, merged) | /knowledge/ |
/blog/swarm-simulator/ | /knowledge/ |
/hop-database/ and /blog/hop-database/ | /projects.html |
/posts/2025/08/trajgenpy-guide/ | /projects.html |
/posts/2025/08/trajallocpy-guide/ | /projects.html |
/posts/2025/08/swarmtalk-guide/ | /projects.html |
/posts/2025/08/sarenv-guide/ | /projects.html |
/posts/2025/08/agent-dsl-guide/ | /knowledge/ |
/posts/2025/08/complete-multi-robot-guide/ | /knowledge/decentralized-task-allocation/ |
/publications/ (old nav) | /publications.html |
/cv/ (old nav) | /cv.html |
GitHub Pages serves static files only — there is no server-side redirect and no .htaccess,
which is why these are client-side stubs rather than 301s. They are deliberately not in
sitemap.xml: they are noindex and exist only to catch inbound traffic.
That is the complete set — every permalink: in the last Jekyll commit (8f9351a) is either
covered above, still valid (/404.html, /), or a post URL listed in the table.
python3 -m http.server 8000
# http://localhost:8000
Use a server, not file:// — pages reference assets with absolute paths (/deck/deck.css).
cp -r knowledge/_template knowledge/my-new-talk
# edit knowledge/my-new-talk/index.html
# add a card to knowledge/index.html (Featured or Archive)
# add the URL to sitemap.xml
# add ("my-new-talk", "Title") to DECKS in tools/build_search_index.py
To port a Google Slides deck, use the import-slides skill in .claude/skills/: it exports the
presentation, extracts text, notes and images with scripts/extract_pptx.py, and lays down the
house style from references/style-guide.md. scripts/check_deck.py validates a deck against
the engine rules and the writing rules in CLAUDE.md; scripts/snap_deck.sh screenshots every
slide with headless Chromium so overflow is caught before it is pushed.
Slides are <section class="slide"> elements inside a fixed 1920×1080 #stage.
deck.js scales that stage with a single CSS transform so it fills any window, shows one
slide at a time, and syncs the index to the URL hash — …/my-deck/#7 deep-links to slide 7.
Because the canvas is a fixed pixel size you design in absolute px and it renders
identically on a laptop, a projector, and a phone.
Rules the engine relies on:
<section class="slide" data-t="Slide title">…</section>class="… active"data-t feeds the contents drawer and the browser tab title<div class="notes">…</div> inside a slide is speaker-only (press n)<ul class="sources"><li><a href="…">Label</a></li></ul> inside a slide becomes its citation list
(press r); the HUD shows a count badge when the current slide has anyclass="slide deep-dive" marks an optional slide — the HUD shows a "Deep dive" badge so you know
it is safe to skip when short on timeclass="slide section" marks a divider and starts a new contents group<img class="zoom"> becomes click-to-enlarge<span class="todo"> marks placeholder content in a draft deck; <div class="draft-flag">Draft</div>
puts a badge in the slide corner| Key | Action |
|---|---|
→ space PageDown | next slide |
← PageUp | previous slide |
Home / End | first / last slide |
n | speaker notes |
o or g | contents |
r | sources for the current slide |
f | fullscreen |
t | light / dark |
p | print → PDF, one slide per page |
Esc | close drawers |
Swipe and scroll wheel also advance.
Kinds — title-slide, section, closing-slide, or plain.
Header — .kicker, h1.t (.sm for smaller), .sub, .grad for gradient text.
Layout — .body, .cols + .grow, .grid-2 / .grid-3 / .grid-4, .stack, .center.
Content — .card (.solid .dark .accent), .card .num for a big figure, ul.bullets,
.chip, pre.code (with .k .s .c spans), figure + figcaption, .quote + .by.
Footer — .slide-foot; slide numbers inject themselves.
Three that have each cost a debugging round:
flex:0 0 NNNpx on a child of a column container (.stack, or anything
flex-direction:column). In a column, flex-basis sets height, and the slide overflows.figure img needs min-height:0 (it has it in deck.css). Without it, an image in a flex
column overflows the moment a <figcaption> shares the box..cols row followed by a sibling inside .body needs flex:none. .body is a column flex
container, so it will shrink the .cols box while the cards inside keep their height — they then
paint over whatever follows. The stage-overflow scanner cannot see this one, only your eyes can.Colour and type tokens live in the :root block at the top of both site/site.css and
deck/deck.css — they are deliberately the same values, so site and slides read as one
system. Change --accent and --canvas and everything follows. For a one-off deck
palette, add <style>:root{--accent:#7a3ea1}</style> to that deck's <head>.
The site pages are light only: there is no dark palette in site.css, no theme toggle and no
theme script. The decks keep their own t key and :root[data-theme="dark"]
block in deck/deck.css, stored under localStorage key deck-theme; the site pages no
longer read that key, so a deck left in dark mode does not darken the rest of the site.
Two patterns, both pure CSS in deck/deck.css, both gated on .slide.active so they replay every
time you land on the slide. Shared easing throughout: cubic-bezier(.16,1,.3,1).
Staggered title. Split the headline into words and they cascade in:
<h1><span class="word">Late</span><span class="word">interaction,</span>
<span class="word accent">up close</span></h1>
Ambient loop. A decorative .signal block (traced SVG paths + a drifting dot grid) runs
infinitely on title slides. Sibling paths get .slow, which applies a negative animation-delay
so they desynchronise immediately instead of waiting a cycle.
Delay ladder. The .pipe diagram reveals itself in stages — tokens at .16s, encoder .3s,
embeddings .44s, pool .6s, pooled vector .78s, score rail .96s, label 1.14s — so the
audience reads it in the order you explain it. Per-arrow timing comes from an inline
style="--d:.34s". See knowledge/late-interaction-primer/ slide 2.
Everything is disabled under @media (prefers-reduced-motion: reduce).
Hand-written inline SVG (see knowledge/decentralized-task-allocation/assets/tasks.svg). Stays
crisp at projector resolution, needs no JS, and uses the same palette tokens.
Keep them small — the previous version of this site shipped 41 MB of unoptimized photos. Re-encode before committing:
python3 -c "
from PIL import Image
im = Image.open('photo.jpg'); w = 1800
im.resize((w, round(im.height*w/im.width)), Image.LANCZOS).save(
'assets/img/photo.jpg', quality=82, optimize=True, progressive=True)"
Animations belong in WebP, not GIF — a 15 MB GIF here became a 363 KB animated WebP.
/search/ searches the whole site with inference-free SPLADE, and puts the sparse vectors on
screen while it does. It runs on GitHub Pages with no inference server because SPLADE is asymmetric:
opensearch-neural-sparse-encoding-doc-v3-distill,
Apache-2.0) into sparse vectors over BERT's 30,522-token vocabulary. That happens here, offline.publications.html, the slides, the projects, the CV
rows, the sections of every blog post — and, for the papers that may be redistributed, the body of the paper too.
content/publications/<id>.txt is split on its numbered headings into passages the size of
the rest of the corpus, each deep-linking to the paper it came from. The search UI never
downloads these files — the browser only gets index.json — but the repo is uploaded to Pages
as-is, so they are public, which is why only redistributable versions are kept here. The IEEE
and Springer published versions are deliberately absent — indexing them
would mean committing a publisher's typesetting to a public repository — so those two papers
are still indexed from their abstracts alone.So the browser downloads the table, the vocabulary and the postings, and search/splade.js does the
rest in about 200 lines with no dependencies. Retrieval is still the whole of /search/, and it is
still the whole of the front page until someone presses Answer this — see The generated
answer below, which is the one part of this site that talks to a server.
search/autocomplete.js is the widget: a combobox whose listbox holds the top results — five on
/search/, four on the front page — each with its rank, kind, score and a bar splitting that score
by contributing term. Both pages use it and differ only in onSelect:
/search/ — pressing a result dissects it in the panels below, and the URL picks up
?q=…&r=<doc id> so any view of the page is shareable..asksite band carries a compact copy, quotes the top hit back under
Closest passage, and offers to generate an answer from it. Pressing a result hands off to
/search/?q=…&r=…, so the reader lands on the analysis of the thing they picked.Each row also carries a corner link straight to the source page, so you can skip the analysis. That
link is the one real <a> in a row: the row itself is a listbox option, activated by click or
Enter. Nothing is fetched until the box is focused, so the front page pays no bytes for this unless
someone actually searches, and nothing leaves the browser until someone presses a button.
The front page band is a conversation. You ask, the page answers, and the answer carries the
passages it was built from. /search/ is unchanged — it is still the surface that shows the
retrieval itself.
Retrieval has not moved: search/splade.js still scores every passage in the browser, and no
query is sent anywhere to be retrieved. Only generation leaves the machine, and only after
retrieval has already chosen the passages it may use.
Three things shape it:
localhost on origin..sp-opt rows /search/ uses, so the two agree by
construction.The worker behind it is worker/, on ask.grontved.xyz — its own hostname rather than a route on
www, so nothing it does can reach the static pages. If it breaks, runs out of quota, or is
deleted, the front page keeps working and stops offering to answer.
Two constraints on the worker:
search/corpus.json, fetched from this site at runtime, so it can only generate from this
site's own corpus. Fetching rather than bundling means CI never needs a Cloudflare deploy token.[1], [2] and never writes a URL.
search/home-search.js resolves those against the ordered id list it sent, so an invented link
is not representable — it stays plain text.It stays free structurally rather than carefully: Workers Free has no overage billing, the account carries no payment method, and Mistral stays on the Experiment tier, so every limit produces an error rather than a charge. The caps inside the worker — 200 characters of question, an origin check, a per-IP daily budget, a site-wide daily ceiling, an answer cache and a circuit breaker — make that degradation rare and quiet; they do not prevent a bill that cannot happen.
Deploying it:
npx wrangler login
npx wrangler kv namespace create ASK_KV # ids go in worker/wrangler.toml
npx wrangler secret put MISTRAL_API_KEY
npx wrangler secret put IP_SALT
npx wrangler deploy # from worker/
wrangler deploy claims the custom domain from wrangler.toml. The one WAF rate limiting rule
(http.request.uri.path eq "/ask", 5 requests per 10 seconds per IP) is dashboard-only, and must
be scoped to that path: the free plan allows exactly one, and its expression can match on path but
not on hostname, so an unscoped rule would throttle the whole zone.
CI does this automatically: .github/workflows/search-index.yml rebuilds and commits the index on
any push touching publications.html, projects.html, cv.html, a deck, or the build script. That
commit then triggers pages.yml, which deploys it. You only need to do it by hand when working
offline:
pip install torch --index-url https://download.pytorch.org/whl/cpu # CPU wheel, much smaller
pip install -r tools/requirements.txt
python3 tools/build_search_index.py --dry-run # extract and report, no model
python3 tools/build_search_index.py --out search # extract, encode, write the index
python3 tools/check_search_index.py # client vs. reference implementation
The build writes search/index.json (postings and metadata), search/qweights.u16.bin (the query
table) and search/vocab.txt (line N is vocabulary token N). It also writes search/corpus.json,
the same passages without their vectors, which the answer worker reads — that one belongs to the
extraction stage, so --dry-run regenerates it in a second and it never needs torch.
Results deep-link into real pages, so every indexed passage needs a stable fragment: id attributes
on details.pub and the thesis/supervision dt rows, on a.card in projects.html, and on
ul.tl > li in cv.html. Slides need nothing — deck.js already addresses them as #N.
Those ids are listed in SLUG_MAP in tools/build_search_index.py, and the extractor fails the
build if the HTML no longer matches, so a renamed section can't silently produce a dead link. Add
the id to the HTML and the slug to SLUG_MAP together.
The CV's Skills, Research output and Contact blocks are indexed one section at a time, with the
· separators read as prose: row by row they are fragments SPLADE makes noise of.
Blog posts need nothing either. Any blog/<slug>/index.html with an article.post is found and split
on its numbered h2 sections, each deep-linking to the heading's id; everything before the first
h2 (the lede and the opening) becomes an Introduction passage linking to the post. Tables are read
row by row ("Tokens: Search 10.6k, Grep 14.3k.") rather than as a soup of cells, figures by their
aria-label and caption, and the reading list (h2#refs) is skipped: citations are not content.
search/index.json carries what the browser shows: kind, title, url, meta, snippet.
search/corpus.json, which the answer worker reads, carries the full text and five provenance fields
on top:
| field | example |
|---|---|
source | Paper, full text · Paper, abstract · Talk slides · Blog post · CV · Project · Thesis |
doc | the paper, deck or post the passage belongs to |
section | Section 3: Methods · Slide 10 of 12: Late interaction sits in the middle · Introduction |
date | publication year, post date, or the dates of a CV row |
page | the page in the source PDF, where known |
worker/src/prompt.js heads every passage with them (Source, Document, Date, Where, Page),
so the model can tell one paper's Introduction from another's and say where a fact comes from,
while still citing only by number. Entries without the fields fall back to title and meta, so an old
worker reads a new corpus and the other way round. The worker fetches the corpus as
corpus.json?v=3; bump that query when the corpus shape changes, and redeploy (npx wrangler deploy
from worker/) for a prompt change to take effect. worker/corpus.json is the bundled fallback;
copy search/corpus.json over it when you rebuild.
Pages are not known yet. The full texts in content/publications/ were extracted without page
breaks, so page is empty for every passage. Re-extracting them with pdftotext (which writes a
form feed between pages by default) and splitting on \f before the section split would give every
paper passage a page, and a #page=N link wherever the PDF itself is public.
assets/cv/cv.pdf is generated from assets/cv/cv-print.html (a print-only variant of
cv.html, marked noindex and not linked from the site) using WeasyPrint, which honours real
CSS so the PDF uses the site's own tokens and webfonts:
uv tool install weasyprint # once
weasyprint assets/cv/cv-print.html assets/cv/cv.pdf
Edit cv.html and cv-print.html together, then regenerate. site/site.css also carries an
@media print block, so cv.html prints cleanly straight from the browser.
Push to master. .github/workflows/pages.yml uploads and deploys. Custom domain
www.grontved.xyz is set by CNAME.
Personal site for Kasper Rømer Grøntved — publications, CV, projects, and a browser-native slide deck engine for talks.
Plain static HTML. No Jekyll, no Ruby, no npm, no build step. GitHub Actions uploads the repo as-is to Pages.
index.html landing page (masthead, fact bar, then a board of tiles: Ask this site, the featured lecture, the four latest talks and posts)
publications.html full publication list with abstracts
cv.html CV in HTML · assets/cv/cv.pdf is the downloadable version
404.html
knowledge/index.html the talks + writing index (featured deck, posts, decks) — the blog index merged into it
knowledge/<slug>/index.html one deck = one HTML file
knowledge/_template/ copy this to start a new deck
search/index.html SPLADE site search (see below)
search/splade.js retrieval engine: WordPiece + sparse dot product
search/autocomplete.js the search box and results dropdown, shared by both pages
search/search-ui.js /search/ only: the breakdown panels
search/home-search.js the front page band: the widget, and the generated answer
search/search.css styles for the box, the dropdown and the panels
search/index.json … the built index (generated — CI rebuilds it)
search/corpus.json the same passages without their vectors (generated)
content/publications/ full paper text, read at build time (public, like everything here)
worker/ the Cloudflare Worker behind "Answer this" (see below)
tools/build_search_index.py extracts the corpus and encodes it
site/site.css shared design system for the site pages
deck/deck.css slide styles, same tokens as site.css
deck/deck.js slide runtime (~200 lines, vanilla JS)
assets/img/ web-optimized images
assets/cv/cv.pdf downloadable CV (generated — see below)
assets/cv/cv-print.html the print source the PDF is rendered from
blog/index.html redirect stub → /knowledge/ (the posts are listed there)
blog/<slug>/index.html one post = one HTML file, hand-written; inline SVG for figures
blog/blog.css article styles (prose, figures, tables, the receipt), on top of site.css
blog/*/ posts/ hop-database/ redirect stubs for the retired Jekyll blog (see below)
publications/ cv/ redirect stubs for the old Jekyll nav URLs
assets/favicon.svg the tab icon: a white K over a diffuse aurora wash on the page canvas; follows the OS theme
favicon.ico the same mark as 16/32/48 px PNGs, for browsers that skip SVG icons (Safari)
sitemap.xml robots.txt CNAME .nojekyll
.nojekyll matters: without it GitHub Pages runs Jekyll and ignores files starting
with _, which would break knowledge/_template/.
There is no navigation bar. The fact bar on the front page carries the outward links (GitHub,
Scholar, LinkedIn, the CV as PDF), the latest tile links to /knowledge/, and every other page
carries a slim, non-sticky header with the brand and a single Front page → link. There is no
index of pages: publications.html, cv.html and search/ are reached from the pages that
cite them (the CV, the Knowledge page, the SPLADE post, the 404) rather than from the front page.
projects.html is retired and deliberately not in the list.
The posts live under /blog/<slug>/, one hand-written index.html per post, no generator, but
the blog has no index page of its own: /blog/ redirects to /knowledge/, which lists the talks
and the posts together (a Writing section between Featured and Talks & decks). Figures are
inline SVG that use the colour tokens (fill="var(--accent)"), so a palette change in site.css
reaches them too. To add a post, copy an existing one, add a row to the Writing list in
knowledge/index.html, and the URL to sitemap.xml. The first post,
blog/inference-free-splade/, is the write-up of the site search below; the numbers in it are read
off the built index, so if the corpus changes materially the post's figures are the thing to
re-check.
The two old Jekyll post directories under blog/ (swarm-simulator, hop-database) are
redirect stubs, not posts.
The site used to be Jekyll with a blog under the permalink pattern /:categories/:title/.
The eight posts are gone and two pages changed address, but the old URLs are still linked to
and indexed, so each one keeps a tiny stub at its old path: blog/, posts/,
hop-database/, publications/ and cv/. Every stub is a single
index.html with a meta refresh, location.replace(), a canonical link to the target,
robots: noindex, and a visible fallback link for anyone with JS off. An HTML comment at the
top of each names the page or post it replaces.
| Old URL | Now goes to |
|---|---|
/posts/ (old blog index) | / |
/blog/ (blog index, merged) | /knowledge/ |
/blog/swarm-simulator/ | /knowledge/ |
/hop-database/ and /blog/hop-database/ | /projects.html |
/posts/2025/08/trajgenpy-guide/ | /projects.html |
/posts/2025/08/trajallocpy-guide/ | /projects.html |
/posts/2025/08/swarmtalk-guide/ | /projects.html |
/posts/2025/08/sarenv-guide/ | /projects.html |
/posts/2025/08/agent-dsl-guide/ | /knowledge/ |
/posts/2025/08/complete-multi-robot-guide/ | /knowledge/decentralized-task-allocation/ |
/publications/ (old nav) | /publications.html |
/cv/ (old nav) | /cv.html |
GitHub Pages serves static files only — there is no server-side redirect and no .htaccess,
which is why these are client-side stubs rather than 301s. They are deliberately not in
sitemap.xml: they are noindex and exist only to catch inbound traffic.
That is the complete set — every permalink: in the last Jekyll commit (8f9351a) is either
covered above, still valid (/404.html, /), or a post URL listed in the table.
python3 -m http.server 8000
# http://localhost:8000
Use a server, not file:// — pages reference assets with absolute paths (/deck/deck.css).
cp -r knowledge/_template knowledge/my-new-talk
# edit knowledge/my-new-talk/index.html
# add a card to knowledge/index.html (Featured or Archive)
# add the URL to sitemap.xml
# add ("my-new-talk", "Title") to DECKS in tools/build_search_index.py
To port a Google Slides deck, use the import-slides skill in .claude/skills/: it exports the
presentation, extracts text, notes and images with scripts/extract_pptx.py, and lays down the
house style from references/style-guide.md. scripts/check_deck.py validates a deck against
the engine rules and the writing rules in CLAUDE.md; scripts/snap_deck.sh screenshots every
slide with headless Chromium so overflow is caught before it is pushed.
Slides are <section class="slide"> elements inside a fixed 1920×1080 #stage.
deck.js scales that stage with a single CSS transform so it fills any window, shows one
slide at a time, and syncs the index to the URL hash — …/my-deck/#7 deep-links to slide 7.
Because the canvas is a fixed pixel size you design in absolute px and it renders
identically on a laptop, a projector, and a phone.
Rules the engine relies on:
<section class="slide" data-t="Slide title">…</section>class="… active"data-t feeds the contents drawer and the browser tab title<div class="notes">…</div> inside a slide is speaker-only (press n)<ul class="sources"><li><a href="…">Label</a></li></ul> inside a slide becomes its citation list
(press r); the HUD shows a count badge when the current slide has anyclass="slide deep-dive" marks an optional slide — the HUD shows a "Deep dive" badge so you know
it is safe to skip when short on timeclass="slide section" marks a divider and starts a new contents group<img class="zoom"> becomes click-to-enlarge<span class="todo"> marks placeholder content in a draft deck; <div class="draft-flag">Draft</div>
puts a badge in the slide corner| Key | Action |
|---|---|
→ space PageDown | next slide |
← PageUp | previous slide |
Home / End | first / last slide |
n | speaker notes |
o or g | contents |
r | sources for the current slide |
f | fullscreen |
t | light / dark |
p | print → PDF, one slide per page |
Esc | close drawers |
Swipe and scroll wheel also advance.
Kinds — title-slide, section, closing-slide, or plain.
Header — .kicker, h1.t (.sm for smaller), .sub, .grad for gradient text.
Layout — .body, .cols + .grow, .grid-2 / .grid-3 / .grid-4, .stack, .center.
Content — .card (.solid .dark .accent), .card .num for a big figure, ul.bullets,
.chip, pre.code (with .k .s .c spans), figure + figcaption, .quote + .by.
Footer — .slide-foot; slide numbers inject themselves.
Three that have each cost a debugging round:
flex:0 0 NNNpx on a child of a column container (.stack, or anything
flex-direction:column). In a column, flex-basis sets height, and the slide overflows.figure img needs min-height:0 (it has it in deck.css). Without it, an image in a flex
column overflows the moment a <figcaption> shares the box..cols row followed by a sibling inside .body needs flex:none. .body is a column flex
container, so it will shrink the .cols box while the cards inside keep their height — they then
paint over whatever follows. The stage-overflow scanner cannot see this one, only your eyes can.Colour and type tokens live in the :root block at the top of both site/site.css and
deck/deck.css — they are deliberately the same values, so site and slides read as one
system. Change --accent and --canvas and everything follows. For a one-off deck
palette, add <style>:root{--accent:#7a3ea1}</style> to that deck's <head>.
The site pages are light only: there is no dark palette in site.css, no theme toggle and no
theme script. The decks keep their own t key and :root[data-theme="dark"]
block in deck/deck.css, stored under localStorage key deck-theme; the site pages no
longer read that key, so a deck left in dark mode does not darken the rest of the site.
Two patterns, both pure CSS in deck/deck.css, both gated on .slide.active so they replay every
time you land on the slide. Shared easing throughout: cubic-bezier(.16,1,.3,1).
Staggered title. Split the headline into words and they cascade in:
<h1><span class="word">Late</span><span class="word">interaction,</span>
<span class="word accent">up close</span></h1>
Ambient loop. A decorative .signal block (traced SVG paths + a drifting dot grid) runs
infinitely on title slides. Sibling paths get .slow, which applies a negative animation-delay
so they desynchronise immediately instead of waiting a cycle.
Delay ladder. The .pipe diagram reveals itself in stages — tokens at .16s, encoder .3s,
embeddings .44s, pool .6s, pooled vector .78s, score rail .96s, label 1.14s — so the
audience reads it in the order you explain it. Per-arrow timing comes from an inline
style="--d:.34s". See knowledge/late-interaction-primer/ slide 2.
Everything is disabled under @media (prefers-reduced-motion: reduce).
Hand-written inline SVG (see knowledge/decentralized-task-allocation/assets/tasks.svg). Stays
crisp at projector resolution, needs no JS, and uses the same palette tokens.
Keep them small — the previous version of this site shipped 41 MB of unoptimized photos. Re-encode before committing:
python3 -c "
from PIL import Image
im = Image.open('photo.jpg'); w = 1800
im.resize((w, round(im.height*w/im.width)), Image.LANCZOS).save(
'assets/img/photo.jpg', quality=82, optimize=True, progressive=True)"
Animations belong in WebP, not GIF — a 15 MB GIF here became a 363 KB animated WebP.
/search/ searches the whole site with inference-free SPLADE, and puts the sparse vectors on
screen while it does. It runs on GitHub Pages with no inference server because SPLADE is asymmetric:
opensearch-neural-sparse-encoding-doc-v3-distill,
Apache-2.0) into sparse vectors over BERT's 30,522-token vocabulary. That happens here, offline.publications.html, the slides, the projects, the CV
rows, the sections of every blog post — and, for the papers that may be redistributed, the body of the paper too.
content/publications/<id>.txt is split on its numbered headings into passages the size of
the rest of the corpus, each deep-linking to the paper it came from. The search UI never
downloads these files — the browser only gets index.json — but the repo is uploaded to Pages
as-is, so they are public, which is why only redistributable versions are kept here. The IEEE
and Springer published versions are deliberately absent — indexing them
would mean committing a publisher's typesetting to a public repository — so those two papers
are still indexed from their abstracts alone.So the browser downloads the table, the vocabulary and the postings, and search/splade.js does the
rest in about 200 lines with no dependencies. Retrieval is still the whole of /search/, and it is
still the whole of the front page until someone presses Answer this — see The generated
answer below, which is the one part of this site that talks to a server.
search/autocomplete.js is the widget: a combobox whose listbox holds the top results — five on
/search/, four on the front page — each with its rank, kind, score and a bar splitting that score
by contributing term. Both pages use it and differ only in onSelect:
/search/ — pressing a result dissects it in the panels below, and the URL picks up
?q=…&r=<doc id> so any view of the page is shareable..asksite band carries a compact copy, quotes the top hit back under
Closest passage, and offers to generate an answer from it. Pressing a result hands off to
/search/?q=…&r=…, so the reader lands on the analysis of the thing they picked.Each row also carries a corner link straight to the source page, so you can skip the analysis. That
link is the one real <a> in a row: the row itself is a listbox option, activated by click or
Enter. Nothing is fetched until the box is focused, so the front page pays no bytes for this unless
someone actually searches, and nothing leaves the browser until someone presses a button.
The front page band is a conversation. You ask, the page answers, and the answer carries the
passages it was built from. /search/ is unchanged — it is still the surface that shows the
retrieval itself.
Retrieval has not moved: search/splade.js still scores every passage in the browser, and no
query is sent anywhere to be retrieved. Only generation leaves the machine, and only after
retrieval has already chosen the passages it may use.
Three things shape it:
localhost on origin..sp-opt rows /search/ uses, so the two agree by
construction.The worker behind it is worker/, on ask.grontved.xyz — its own hostname rather than a route on
www, so nothing it does can reach the static pages. If it breaks, runs out of quota, or is
deleted, the front page keeps working and stops offering to answer.
Two constraints on the worker:
search/corpus.json, fetched from this site at runtime, so it can only generate from this
site's own corpus. Fetching rather than bundling means CI never needs a Cloudflare deploy token.[1], [2] and never writes a URL.
search/home-search.js resolves those against the ordered id list it sent, so an invented link
is not representable — it stays plain text.It stays free structurally rather than carefully: Workers Free has no overage billing, the account carries no payment method, and Mistral stays on the Experiment tier, so every limit produces an error rather than a charge. The caps inside the worker — 200 characters of question, an origin check, a per-IP daily budget, a site-wide daily ceiling, an answer cache and a circuit breaker — make that degradation rare and quiet; they do not prevent a bill that cannot happen.
Deploying it:
npx wrangler login
npx wrangler kv namespace create ASK_KV # ids go in worker/wrangler.toml
npx wrangler secret put MISTRAL_API_KEY
npx wrangler secret put IP_SALT
npx wrangler deploy # from worker/
wrangler deploy claims the custom domain from wrangler.toml. The one WAF rate limiting rule
(http.request.uri.path eq "/ask", 5 requests per 10 seconds per IP) is dashboard-only, and must
be scoped to that path: the free plan allows exactly one, and its expression can match on path but
not on hostname, so an unscoped rule would throttle the whole zone.
CI does this automatically: .github/workflows/search-index.yml rebuilds and commits the index on
any push touching publications.html, projects.html, cv.html, a deck, or the build script. That
commit then triggers pages.yml, which deploys it. You only need to do it by hand when working
offline:
pip install torch --index-url https://download.pytorch.org/whl/cpu # CPU wheel, much smaller
pip install -r tools/requirements.txt
python3 tools/build_search_index.py --dry-run # extract and report, no model
python3 tools/build_search_index.py --out search # extract, encode, write the index
python3 tools/check_search_index.py # client vs. reference implementation
The build writes search/index.json (postings and metadata), search/qweights.u16.bin (the query
table) and search/vocab.txt (line N is vocabulary token N). It also writes search/corpus.json,
the same passages without their vectors, which the answer worker reads — that one belongs to the
extraction stage, so --dry-run regenerates it in a second and it never needs torch.
Results deep-link into real pages, so every indexed passage needs a stable fragment: id attributes
on details.pub and the thesis/supervision dt rows, on a.card in projects.html, and on
ul.tl > li in cv.html. Slides need nothing — deck.js already addresses them as #N.
Those ids are listed in SLUG_MAP in tools/build_search_index.py, and the extractor fails the
build if the HTML no longer matches, so a renamed section can't silently produce a dead link. Add
the id to the HTML and the slug to SLUG_MAP together.
The CV's Skills, Research output and Contact blocks are indexed one section at a time, with the
· separators read as prose: row by row they are fragments SPLADE makes noise of.
Blog posts need nothing either. Any blog/<slug>/index.html with an article.post is found and split
on its numbered h2 sections, each deep-linking to the heading's id; everything before the first
h2 (the lede and the opening) becomes an Introduction passage linking to the post. Tables are read
row by row ("Tokens: Search 10.6k, Grep 14.3k.") rather than as a soup of cells, figures by their
aria-label and caption, and the reading list (h2#refs) is skipped: citations are not content.
search/index.json carries what the browser shows: kind, title, url, meta, snippet.
search/corpus.json, which the answer worker reads, carries the full text and five provenance fields
on top:
| field | example |
|---|---|
source | Paper, full text · Paper, abstract · Talk slides · Blog post · CV · Project · Thesis |
doc | the paper, deck or post the passage belongs to |
section | Section 3: Methods · Slide 10 of 12: Late interaction sits in the middle · Introduction |
date | publication year, post date, or the dates of a CV row |
page | the page in the source PDF, where known |
worker/src/prompt.js heads every passage with them (Source, Document, Date, Where, Page),
so the model can tell one paper's Introduction from another's and say where a fact comes from,
while still citing only by number. Entries without the fields fall back to title and meta, so an old
worker reads a new corpus and the other way round. The worker fetches the corpus as
corpus.json?v=3; bump that query when the corpus shape changes, and redeploy (npx wrangler deploy
from worker/) for a prompt change to take effect. worker/corpus.json is the bundled fallback;
copy search/corpus.json over it when you rebuild.
Pages are not known yet. The full texts in content/publications/ were extracted without page
breaks, so page is empty for every passage. Re-extracting them with pdftotext (which writes a
form feed between pages by default) and splitting on \f before the section split would give every
paper passage a page, and a #page=N link wherever the PDF itself is public.
assets/cv/cv.pdf is generated from assets/cv/cv-print.html (a print-only variant of
cv.html, marked noindex and not linked from the site) using WeasyPrint, which honours real
CSS so the PDF uses the site's own tokens and webfonts:
uv tool install weasyprint # once
weasyprint assets/cv/cv-print.html assets/cv/cv.pdf
Edit cv.html and cv-print.html together, then regenerate. site/site.css also carries an
@media print block, so cv.html prints cleanly straight from the browser.
Push to master. .github/workflows/pages.yml uploads and deploys. Custom domain
www.grontved.xyz is set by CNAME.