kasperg3/kasperg3.github.io

www.grontved.xyz

HTML

1

163 commits

updated Oct 16, 2026

See the code

README

grontved.xyz

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.

Layout

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.

Blog

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.

Legacy URL redirects

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 URLNow 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.

Local preview

python3 -m http.server 8000
# http://localhost:8000

Use a server, not file:// — pages reference assets with absolute paths (/deck/deck.css).

Adding a deck

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.

How the deck engine works

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:

  • every slide is <section class="slide" data-t="Slide title">…</section>
  • the first slide also gets 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 any
  • class="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 time
  • class="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

Presenter keys

KeyAction
→ space PageDownnext slide
← PageUpprevious slide
Home / Endfirst / last slide
nspeaker notes
o or gcontents
rsources for the current slide
ffullscreen
tlight / dark
pprint → PDF, one slide per page
Escclose drawers

Swipe and scroll wheel also advance.

Slide building blocks

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.

Layout gotchas

Three that have each cost a debugging round:

  • Never put 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.
  • A .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.

Theming

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.

Animation

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).

Charts

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.

Images

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:

  • Documents are encoded by a 67M-parameter masked LM (opensearch-neural-sparse-encoding-doc-v3-distill, Apache-2.0) into sparse vectors over BERT's 30,522-token vocabulary. That happens here, offline.
  • Passages are the abstracts on 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.
  • Queries need no model at all — just a WordPiece tokenizer and a static per-token weight table that ships as a text file. Scoring is a sparse dot product over every document; at this corpus size an inverted index would buy nothing.

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.

The two search surfaces

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.
  • the front page — the .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 generated answer

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:

  • Each question stands alone. Nothing is carried between turns, so a follow-up is not understood as one. The thread is a record, not a memory, and the copy never implies otherwise.
  • Generation is the optional half. If the worker 4xxs, 5xxs, is rate limited or simply is not there, the turn falls back to quoting the best retrieved passage. Retrieval alone is still an answer, so no failure leaves the reader with nothing. This is the common path in local development, where the worker rejects localhost on origin.
  • The analysis moved, it did not go. Every turn has a How this was retrieved button opening the query terms and their weights, the ranked passages, and which of them the model was actually given. It renders with the same .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:

  • The client sends passage ids, never passage text. It resolves them against 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.
  • Citations cannot be fabricated. The model cites as bare [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.

Rebuilding the index

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.

Adding content the index can reach

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.

What travels with a passage

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:

fieldexample
sourcePaper, full text · Paper, abstract · Talk slides · Blog post · CV · Project · Thesis
docthe paper, deck or post the passage belongs to
sectionSection 3: Methods · Slide 10 of 12: Late interaction sits in the middle · Introduction
datepublication year, post date, or the dates of a CV row
pagethe 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.

Regenerating the CV PDF

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.

Deploy

Push to master. .github/workflows/pages.yml uploads and deploys. Custom domain www.grontved.xyz is set by CNAME.

kasperg3/kasperg3.github.io

www.grontved.xyz

HTML

1

163 commits

updated Oct 16, 2026

See the code

README

grontved.xyz

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.

Layout

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.

Blog

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.

Legacy URL redirects

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 URLNow 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.

Local preview

python3 -m http.server 8000
# http://localhost:8000

Use a server, not file:// — pages reference assets with absolute paths (/deck/deck.css).

Adding a deck

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.

How the deck engine works

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:

  • every slide is <section class="slide" data-t="Slide title">…</section>
  • the first slide also gets 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 any
  • class="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 time
  • class="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

Presenter keys

KeyAction
→ space PageDownnext slide
← PageUpprevious slide
Home / Endfirst / last slide
nspeaker notes
o or gcontents
rsources for the current slide
ffullscreen
tlight / dark
pprint → PDF, one slide per page
Escclose drawers

Swipe and scroll wheel also advance.

Slide building blocks

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.

Layout gotchas

Three that have each cost a debugging round:

  • Never put 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.
  • A .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.

Theming

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.

Animation

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).

Charts

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.

Images

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:

  • Documents are encoded by a 67M-parameter masked LM (opensearch-neural-sparse-encoding-doc-v3-distill, Apache-2.0) into sparse vectors over BERT's 30,522-token vocabulary. That happens here, offline.
  • Passages are the abstracts on 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.
  • Queries need no model at all — just a WordPiece tokenizer and a static per-token weight table that ships as a text file. Scoring is a sparse dot product over every document; at this corpus size an inverted index would buy nothing.

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.

The two search surfaces

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.
  • the front page — the .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 generated answer

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:

  • Each question stands alone. Nothing is carried between turns, so a follow-up is not understood as one. The thread is a record, not a memory, and the copy never implies otherwise.
  • Generation is the optional half. If the worker 4xxs, 5xxs, is rate limited or simply is not there, the turn falls back to quoting the best retrieved passage. Retrieval alone is still an answer, so no failure leaves the reader with nothing. This is the common path in local development, where the worker rejects localhost on origin.
  • The analysis moved, it did not go. Every turn has a How this was retrieved button opening the query terms and their weights, the ranked passages, and which of them the model was actually given. It renders with the same .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:

  • The client sends passage ids, never passage text. It resolves them against 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.
  • Citations cannot be fabricated. The model cites as bare [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.

Rebuilding the index

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.

Adding content the index can reach

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.

What travels with a passage

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:

fieldexample
sourcePaper, full text · Paper, abstract · Talk slides · Blog post · CV · Project · Thesis
docthe paper, deck or post the passage belongs to
sectionSection 3: Methods · Slide 10 of 12: Late interaction sits in the middle · Introduction
datepublication year, post date, or the dates of a CV row
pagethe 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.

Regenerating the CV PDF

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.

Deploy

Push to master. .github/workflows/pages.yml uploads and deploys. Custom domain www.grontved.xyz is set by CNAME.