Skip to content

PRDCT-360: rework the 404 page and add the Ink-dash octopus game - #1056

Merged
jordanrburger merged 15 commits into
mainfrom
docs/404-page-inkdash
Aug 5, 2026
Merged

jordanrburger merged 15 commits into
mainfrom
docs/404-page-inkdash

Conversation

@Iamfle4ka

@Iamfle4ka Iamfle4ka commented Jul 30, 2026 •

Copy link
Copy Markdown
Collaborator

What

Reworks the 404 page so it helps with the most common way people land there — a typo — and turns the octopus into something worth playing without letting it take the page over.

The 404 was an entry in the docs collection, so it inherited chrome that makes no sense there: the right-hand "On this page" rail, the Copy as Markdown split button (whose View as Markdown led nowhere), the section eyebrow pill and prev/next pagination. It opened with a 400×390 PNG whose baked-in white background rendered as a white square in the dark theme, then a long tail of links.

It is now a real Astro page rendered through <StarlightPage>. The sidebar stays — on a 404 the sidebar is the answer to "where was I supposed to go" — and everything else goes.

Helping with the typo

Visiting /storage/data-strems/ used to produce a page that never said which path had failed, offered nothing close to it, and opened the search box empty although the URL already said what was wanted. Now it:

  • names the address that failed, on one quiet grey line that doubles as the lead-in to the guesses — /storage/data-strems/ · did you mean:, or · nothing matches that address when nothing scores. Michal asked for this line to go; not naming the address is precisely what led him to read a working link as broken, so it stayed and stopped shouting instead;
  • offers up to three closest real pages (Data Streams first for that example);
  • falls back to the longest existing ancestor section (/storage/), which helps even when nothing scores well;
  • ends in the two moves that always work: Home and Search, side by side. Home is a real <a href="/"> so middle-click and open-in-new-tab behave; Search opens Starlight's modal, prefilled with the top guess (and empty on /404.html). Both carry Starlight's not-content class, because its prose link rule outranks a plain class and painted the Home label accent-blue on the accent-blue fill — an empty blue rectangle. The Documentation home bullet left the list below, since it is now the same link twice.

Matching runs against a list of real slugs embedded at build time, not through Pagefind. The problem is a mistyped address and Pagefind indexes content: searching "data strems" returns Snowflake and Streamlit pages, while distance-to-real-slugs returns Data Streams first. It also keeps working in npm run dev, where Pagefind isn't built, with nothing async to race. ~297 pages cost about 20 KB of raw JSON on a page that is already 95 KB of HTML.

It stays quiet when it should: nothing above a similarity threshold, so /wp-login.php gets no invented guess, and the whole block hides on /404.html and /, where claiming "there's no page at /404.html" would be false.

The game: a scene that is compact until you play

Three shapes were tried and the third is the one that holds. A permanently tall framed panel pushed the recovery links below the fold. A frameless Chrome-dinosaur strip fixed that and killed the game — flat, unreadable, "decoration" in the sense of nothing. What ships is compact while resting, full field only while playing: ~215px at rest, 462px once a game starts, and start() scrolls the grown field into view when it does not fit.

Where it sits was tried both ways. Michal's note offered "a bit of spacing or right at the top", we took the top, and in place it put the octopus ahead of everything that actually recovers the visit. It now follows the Home/Search buttons and precedes the fallback link list — failed address, closest pages, the two exits, and only then the thing to do if none of that helped. Measured at an 800px-tall viewport: suggestions at 215, buttons at 387, panel 459–675, links heading 707, first link 748. Game-first those were 463 / 634 / 215 / 714 / 755 — the recovery block used to start halfway down the screen, while the link list lands in the same place either way. The compact resting state (~215px) is what keeps any of this affordable, and the full field is only taken while somebody is playing and nobody is reading the links.

The scene is open water: no seabed, no floor line (both were added for the shallow strip and made it read as a tank). Depth comes from four things instead:

  • a saturated wash — sea blue #cfe6f5 → #7fb4d8 in the light theme, deep navy #0a1b30 → #16456e in the dark one — with a third stop a third of the way down, because a plain two-stop ramp left the light theme's upper half near white and the whole panel read as a marketing banner;
  • three slanted light shafts, breathing in alpha only;
  • particulate whose size, speed and opacity are correlated, so the small faint slow motes read as far away, with a handful of large ones drifting in front of the player;
  • a vignette, so the panel has a centre rather than meeting its frame at full strength.

Cubes and enemies carry a halo drawn from a cached radial-gradient bitmap per colour and theme — ctx.shadowBlur is the obvious way and costs a full-canvas blur per object, which is a measurable stall at twenty of them on a phone.

The other half of the cure was scale: the world is 840 units wide, not 960, so everything is drawn ~16% bigger in the same box, and the octopus is back to the proportions of the first version — big, centred and mid-water at rest, with the copy as one centred row along the bottom instead of a stack that forced it small or off to one side.

Enemies, and the level-15 squid

The roster is a data table, not scattered conditionals: 404, 500, 403, 429, 502 drift in one new code per level, level 6 introduces 504 as a chaser that steers instead of bouncing, and level 15 introduces 503 — a big squid.

The squid is the only enemy that moves in bursts. It stalks the octopus at a steady 2 units (the player's cap is 7), winds up for ~0.4s — arms gathered, mantle squashed, which is the tell the dodge is owed — and then jets at 12.4 with no steering at all: the direction is sampled once and everything after that is ballistic, bouncing off walls until friction or the timer returns it to a stalk. Radius 30 against the jellyfish 18, with a forgiving hitbox so it feels like the mantle rather than the tentacle spread. One from level 15, a second at 19, a third at 23.

With a squid on the field the swarm gets out of its way: 4 jellyfish instead of the usual 9, 3 once there are two squids. A full field plus a charging boss was unreadable chaos — the boss is the level.

The difficulty curve, after playtesting

Michal played it repeatedly and every run ended the same way: level 10, all three lives gone, nobody ever reaching the boss. The numbers explained it. Speed climbed linearly (1.7 + 0.28 per level) and passed the player's own cap of 7 around level 20 — those levels were not hard, they were unplayable, with nothing to dodge to. The crowd hit its ceiling of 11 at level 9. Lives were fixed at three. And a hit always respawned the player at the same spot with 1.2s of grace, which on a crowded level means landing back in the crowd that just hit them — the rest of the lives went in seconds.

Four changes, aimed at you can keep playing, not at it is easy now:

  • speed is asymptotic, 1.7 + 3(1 − e^−(level−1)/6), approaching 4.7 and staying well under 7 — pressure past the plateau comes from density and from the chasers, which a player can answer;
  • the crowd grows every other level and stops at 9;
  • a life back every fifth level, capped at five, announced on the level banner (Level 10 · +1 life): three at the start, four from 5, five from 10;
  • respawn picks the emptiest of six candidate spots — the one whose nearest enemy is furthest — and grace goes 1.2s → 1.5s.
level speed old → new enemies lives
5 2.82 → 3.16 7 → 6 4
9 3.94 → 3.91 11 → 9 4
10 4.22 → 4.03 11 → 9 5
15 5.62 → 4.41 4 (boss) 5
19 6.74 → 4.55 3 (2 bosses) 5
23 7.86 → 4.62 3 (3 bosses) 5

Early levels come out a touch faster (2.55 vs 2.26 on level 3) with one fewer enemy — a wash. The boss stays on 15: the point was to make 15 reachable, not to move it.

Enemies differ by silhouette, not by their number: dome drifters, a spiked diamond chaser, and the squid drawn along its heading with head and tentacles leading and mantle and fins trailing, plus a lighter rim so the darkest body in the roster still separates from deep navy. Labels are dropped below ~7 CSS px, so on a phone the number is invisible and the shape has to carry it.

Two fixes worth calling out

Sticking to the walls. The player's position was clamped at the field edges but the velocity was not. Holding a direction into a wall kept accelerating, so vx sat at -MAX_SPEED while the octopus looked parked, and letting go left friction plus the opposite input a full second of work before anything moved — it read as being stuck. The into-wall component is now zeroed; zeroed, not reflected, because a player bouncing off scenery reads as losing control too. Pointer targets are clamped into the play area for the same reason: a finger dragged up into the HUD band used to leave the octopus pushing at the ceiling forever.

The Stop button is half the size it was (74×40 → 43×21). It is an escape hatch for anyone without an Esc key, not something anybody aims for, and at full size it took a visible bite out of the field's corner. It only looks that small: a ::before gives it a 44px tap target, because on a phone it is the only way out — there is no Esc, and drag-to-swim means the field's edges are not an exit either.

Cost, because this is an easter egg and not an engine

  • The loop stops entirely when the panel is off-screen (IntersectionObserver) or the tab is in the background, and resumes with a fresh clock so dt cannot jump.
  • The resting panel runs at half rate, carrying skipped time forward so the drift keeps its speed rather than halving.
  • The wash and vignette gradients are built once per box/theme/level instead of once per frame.
  • Particulate capped at 40 motes (was up to 60), shafts 3 (was 4).

The whole component is 14 486 bytes of JS, up from 11 350 before the squid and the scene. No new dependencies, no sprite assets — everything is still drawn in code, so it stays crisp on retina and follows the theme.

Deployment contract

The output path does not move. Astro still emits dist/404.html at the root, which is what production serves. Starlight ships its own routes/static/404.astro, which collides with a user page at that route — a warning now, a hard error in later Astro versions — so disable404Route: true is set.

Note for anyone sharing the page: /overview/404.html is not a route (it never was). Vercel answers any unmatched path with 404.html, so that URL does show this page — which has already led one reviewer to conclude the link was broken. Share a plausible-typo URL like /storage/data-strems/ instead; it is also the honest experience, suggestions included.

Notes for review

Each of these was a bug first:

  • The keyboard is never stolen. Movement keys are preventDefault-ed only while a game runs. Starting is a real <button> covering the panel, so click, tap, space and Enter all work without a global space handler — hijacking space would have taken scrolling from the page.
  • Teardown on astro:before-swap. This site runs ClientRouter, and a broken internal link reaches the 404 through a client-side swap; without teardown the rAF loop and window listeners leak.
  • Init does not depend on catching the initial astro:page-load. Astro emits page-level scripts after </html>, which is why the search button silently never bound at first.
  • The search button defers its click. Starlight's openModal() registers a window-level click-outside-closes-me handler as it opens, so triggering it synchronously let our own still-bubbling click close the modal instantly.
  • The auto-scroll uses window.scrollTo, not element.scrollIntoView. An ancestor in the Starlight layout is itself a scroll container, and scrollIntoView moved the page all of 5px while the field stayed below the fold.
  • HUD text is sized in CSS pixels, not world units — fixed world-unit text rendered the score at ~5px on a phone.
  • CSS owns the panel's size (two aspect ratios plus a min-height per state); the script only reads it. Both states share one world width, which is what keeps the octopus the same size when the panel grows.
  • Below a phone width the panel stops shrinking and the world narrows instead. At 87px tall the Stop button alone covered a third of the play area.
  • Enemies spawn across the whole field, clear of the player and each other, with radius-scaled distances so the squid gets the room it needs.
  • Literal #fff on accent-coloured buttons: Starlight flips --sl-color-white to near black in the light theme.

Verification

npm run build clean. dist/404.html at the root and no dist/404/; page absent from the Pagefind index and marked noindex.

Typo helper, in a production preview: /storage/data-strems/ shows the quiet lead line, suggests Data Streams first and offers /storage/; /compentns/ suggests Components; /zzz/qqq/ shows "nothing matches that address" with no guesses; /404.html hides the block but keeps both buttons; search opens prefilled with "Data Streams" on the typo and empty on /404.html; Home resolves to /; the recovery list now starts at connection.keboola.com.

A page-walk of the deployed preview (Playwright, four viewports, both themes, reduced motion) found two real bugs, both fixed here. Search prefill was garbage on the one URL that started all this: /overview/404.html published its raw last segment, so the page's own call to action opened Pagefind on "404.html" — eight irrelevant hits, confirming the "this is broken" reading it exists to dispel. The fallback query is now sanitised and, when nothing sensible remains, not published at all. Arrow keys stopped scrolling the page while a game ran off-screen: the keyboard gate now also requires the panel to be in the viewport, measured with getBoundingClientRect at the moment the key arrives rather than read from the IntersectionObserver, which is async, carries an 80px margin and never fires in a background tab. Same pass: the hint line measured 3.71:1 on the light theme's water and is now theme white; the "optional game" note moved ahead of the button and is tied to it with aria-describedby; start() no longer drops focus to <body>; Stop keeps its 43×21 look but gets a 44px tap target; and below 600px the hint swaps to "Drag to swim · tap Stop to leave" instead of disappearing.

Game, in a production build: both themes in both states; the 338px-wide panel (min-height holds, the world narrows, labels stay legible, HUD compacts to L15 · ●●● · 0); auto-scroll on Play from a scrolled-to-top page; death gives "Caught by a 404 — score N." and Esc/Stop return to the compact panel; the depth ramp measured per level (#87b7d8 → #698cc6 in the light theme, navy to indigo in the dark one); one rAF loop surviving two view-transition reboots (59 → 61 fps).

Level 15 was checked by temporarily starting the game at that level in a local build — the override is not in this branch: one squid plus four jellyfish, the banner "Level 15 · 503 incoming", and "Caught by a 503" on death.

On this PR's deployed preview: /404.html returns 200 with the game present; the shipped bundle carries the squid behaviour, the wall-clamp fix, the viewport-aware keyboard gate and both pause paths; page order is suggestions → Home/Search → game → recovery links. prefers-reduced-motion holds up under emulation — canvas pixels byte-identical over 600ms.

Not verified live, and worth a reviewer's eyes: per-frame timings and the off-screen pause as a measurement rather than as code plus a bundle check; the squid's wind-up frame never caught in a screenshot; levels 19 and 23 (two and three squids); and touch on a real phone, where only the narrow-panel geometry was exercised.

Known, not fixed here: the suggestion index holds canonical slugs only, so /transformations/pyhton/ misses Python Transformation (a redirect alias) and prefills the search with "dbt Transformation"; and eight duplicate-title pairs (extractor vs writer PostgreSQL, MySQL, BigQuery…) can rank the wrong one first — the grey path under each guess is what disambiguates. Outside this PR: /api/ is shadowed by Vercel's functions directory and answers with Vercel's own raw 404, and /wp-login.php is stopped by the firewall with a 403, so the bot-noise threshold never actually engages in production.

Note on hosting

The site is served by Vercel, so 404.html at the build root is what production returns for an unmatched path — verified on this PR's preview: /compentns (no trailing slash) returns 404 with this page, /components/ returns 200. The aws s3 sync step in .github/workflows/main.yml still runs on every push to main and AGENTS.md still calls it the production deploy; that description is out of date (corrected in #1057) but does not affect this change.

The 404 lived in the docs content collection, so it inherited chrome that
makes no sense there: the right-hand TOC, the "Copy as Markdown" split
button (whose "View as Markdown" led nowhere), the section eyebrow pill and
prev/next pagination. It also opened with a 400x390 PNG whose white
background showed as a white square in the dark theme, followed by a long
tail of links.

It is now a real Astro page rendered through <StarlightPage>, which keeps the
sidebar — on a 404 the sidebar *is* the answer to "where was I supposed to
go" — and drops the rest. Content is trimmed to a search call to action plus
four links, and the octopus is now playable: Ink-dash, a small canvas game
where the Keboola octopus collects data cubes and dodges 404 jellyfish.

The output path does not move: Astro still emits dist/404.html at the root,
which is what production serves. Starlight's own routes/static/404.astro
would otherwise collide with this page (a warning today, a hard error in
later Astro versions), so disable404Route is set.

Notes on the implementation:

- Movement keys are only preventDefault-ed while a game is running, so the
  page keeps its normal keyboard scrolling otherwise.
- The game tears down on astro:before-swap. This site runs ClientRouter and
  a broken internal link lands here through a client-side swap, so a leaked
  rAF loop and window listeners were a real risk.
- Init does not rely on catching the initial astro:page-load: whether a
  module runs before ClientRouter dispatches it is a bundling-order detail.
- The search button defers its click to the next task. Starlight's
  openModal() registers a window-level "click outside closes me" handler as
  it opens, so triggering it synchronously let our own still-bubbling click
  reach that fresh handler and slam the modal shut.
- HUD text is sized in CSS pixels rather than world units. Fixed world-unit
  text shrinks with the panel, which put the score at ~5px on a phone.
- A Stop button sits outside the overlay, since the overlay is hidden while
  playing and Esc is the only other way out — which touch users do not have.
- Buttons on the accent background use literal #fff; Starlight flips
  --sl-color-white to near black in the light theme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
connection-docs Ready Ready Preview Aug 5, 2026 5:35pm

Request Review

Landing on the 404 through a plausible typo (/storage/data-strems/) showed the
page could not help with the most common way people get here: it never said
which path had failed, offered nothing close to it, and opened the search box
empty even though the URL already said what was wanted.

NotFoundSuggestions now echoes the requested path, offers up to three closest
real pages, falls back to the longest existing ancestor section, and hands the
search button a query. Matching runs against a list of real slugs embedded at
build time rather than through Pagefind: the problem is a mistyped *address*
and Pagefind indexes content, so searching "data strems" returned Snowflake and
Streamlit pages while distance-to-real-slugs returns Data Streams first. It also
keeps working in `npm run dev`, where Pagefind isn't built, with nothing async
to race. ~297 pages cost about 20 KB of raw JSON on a page that is already 95 KB.

The game is reshaped after Chrome's offline dinosaur: a wide, shallow, frameless
strip with no fill, no bordered panel, no big Play button and no instruction
block. That was not cosmetic — measured at a 760px viewport, the old 406px panel
put the recovery links at 787px, below the fold. They now sit at 708 and 749.
Room came from the strip, from dropping a lede that repeated what the new block
says, and from removing a standalone support@ line duplicating the list below.

Enemies became a data table instead of one hardcoded 404: 404, 500, 403, 429 and
502 drift in, one new code per level, and level 6 introduces 504 as a chaser that
steers toward the player. It differs by SHAPE — a dark diamond against domes —
because labels are dropped on narrow strips and on a phone the number is
invisible. Depth is carried by the seabed and kelp shifting cooler with each
level, never by a background wash, which is what made this read as a widget.

Details worth knowing:

- Starting is a real <button> covering the strip, so click, tap, space and Enter
  all work without a global space handler. Hijacking space would have taken
  scrolling away from the page — the same class of bug already fixed for arrows.
- CSS owns the strip's size (aspect ratio plus a min-height) and the script only
  reads it. Setting the height from the script caused a layout shift and risked
  a ResizeObserver feedback loop.
- Below a phone width the strip stops shrinking and the world narrows instead.
  At 87px tall the 40px Stop button alone covered half the play area.
- Enemies spawn across the whole strip, clear of the player and of each other.
  Confining spawns to one half — the first attempt at "don't spawn on the
  player" — bunched later levels into a wall and left the other half safe.
- The suggestions block hides itself on /404.html and /, where claiming "there's
  no page at /404.html" would be false, and shows nothing above a similarity
  threshold, so /wp-login.php gets no invented guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Iamfle4ka Iamfle4ka changed the title Rework the 404 page and add the Ink-dash octopus game PRDCT-360: rework the 404 page and add the Ink-dash octopus game Aug 3, 2026
@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown

PRDCT-360

Nikita and others added 4 commits August 3, 2026 23:25
The framed, water-filled panel is back — the frame, the gradient wash and a
full-size octopus are what made it worth looking at, and the flat strip that
replaced them read as nothing at all. What was actually wrong with the original
was its height, not its looks: a permanent 405px panel pushed the recovery links
below the fold.

So the size is now what changes, not the styling. The panel rests at 187px and
grows to the original 405px field the moment a game starts — nobody is reading
the links while playing, so that is when the height is free to take. Two world
heights (250 / 540) pair with two CSS aspect ratios chosen so the drawing scale
stays at ~0.75 CSS px per world unit either way: the octopus does not change
size when the panel grows, only more water appears.

The resting scene splits horizontally instead of stacking — octopus left of
centre, message and Play pill pinned bottom right — which is what lets the
octopus keep its size in a panel this short. The water wash also carries the
depth ramp that the seabed and kelp already followed, so levels still get colder
as they climb, and the enemy cap goes 8 → 11 now that the field is twice as deep.

Measured on a real typo URL (/storage/data-strems/) at a 760px viewport: the
links heading lands at 705 and the first link at 749 — the same above-the-fold
position the strip bought, now with the panel that earns its space. Verified at
1280/760/390 in both themes: arrows scroll the page while idle and are captured
only during play, Esc and Stop both shrink the panel back and record the high
score, and below 600px the message and keyboard hint give way so the pill and
the octopus never collide. Build clean, dist/404.html still at the root, no
dist/404/ directory, audit-phase2 delta 0.

Also corrects the deployment note in 404.astro: production is served by Vercel,
which is what returns dist/404.html for unmatched paths — not the S3 bucket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel had two problems that pulled against each other. Sized to be playable
it pushed the actual recovery links below the fold; sized to leave them alone it
was a flat letterbox strip whose pale two-stop gradient read as a marketing
banner rather than as water, with small sprites floating in an empty rectangle.

Move it to the END of the page, after the links and the partner addresses. It
then costs the page nothing, so it is free to be a scene — and a screen reader
meets the useful part first. It still rests compact and grows to the full field
on Play (a page that ends in a big abandoned play field looks like a mistake),
and start() now scrolls the field into view, because growing downward from the
last element would otherwise leave half of it below the fold. That scroll uses
window.scrollTo rather than element.scrollIntoView: an ancestor in the Starlight
layout is a scroll container, and scrollIntoView moved the page all of 5px.

Drop the seabed line and its kelp — added when the panel was a shallow strip,
they made it read as a tank. Open water instead, with depth built from four
things: a saturated wash (sea blue in the light theme, deep navy in the dark
one) that takes a third stop a third of the way down so the upper half stops
sitting near white; slanted light shafts; particulate whose size, speed and
opacity are correlated so the small faint slow motes read as far away, with a
few large ones drifting in FRONT of the player; and a vignette so the scene has
a centre. Cubes and jellyfish get a cached radial-gradient halo — drawImage of a
per-colour, per-theme bitmap, not ctx.shadowBlur, which costs a full-canvas blur
per object and would stall a phone at twenty of them.

The other half of the cure is scale: the world is 840 units wide instead of 960,
so everything is drawn ~16% bigger in the same box, and the octopus goes back to
the proportions of the version this returns to — big, centred, mid-water, with
the copy as one centred row along the bottom instead of a stack that forced it
small or off to one side.

Verified in a production build: recovery block fully above the fold at 800px
height, both themes in both states, a 338px-wide panel (min-height holds, the
world narrows, labels stay legible), auto-scroll on Play, death and Esc/Stop
returning to the compact panel, the depth ramp shifting sea blue to indigo, and
one rAF loop surviving two view-transition reboots (59 → 61 fps).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Placement: the panel returns to sitting directly after the "Search the
documentation" button, before "Where you might be headed". That spends the
recovery links' fold budget again — at 1280x800 the heading now lands right at
the fold (740px of 779) and the link list runs ~150px past it — which is exactly
what the compact resting state is for: ~215px at rest, the full 462px field only
while somebody is playing and nobody is reading the links. start() still scrolls
the grown field into view, which matters more here than at the page end: the
panel grows downward from mid-page.

Stop button: half the size, 74x40 -> 43x21 (min 30x20, 3px/8px padding, 11px
text). It is an escape hatch for anyone without an Esc key, not something anybody
aims for, and at full size it took a visible bite out of the field's corner.
Deliberately under the 44px touch guideline: Esc and simply stopping play are the
primary exits, and a missed tap costs nothing.

Comments in all three files updated to match — the previous set justified the
end-of-page placement and a 40px button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Squid (503). From level 15 one appears, a second at 19, a third at 23. It stalks
the octopus at a steady 2 units — slower than the player's 7 — then winds up for
~0.4s and jets at 12.4 with NO steering at all: direction is sampled once, and
after that it is ballistic, bouncing off walls until friction or the timer brings
it back to a stalk. The wind-up gathers its arms and squashes the mantle, which is
the tell the dodge is owed. Radius 30 against the jellyfish 18, with a forgiving
hitbox (mantle, not tentacle spread), and its own silhouette — head and tentacles
leading, mantle and fins trailing, a lighter rim so the darkest body in the roster
still separates from deep navy.

The swarm gets out of its way: with a squid on the field the jellyfish count drops
to 4 (3 once there are two squids) instead of the usual 11. Eleven jellyfish plus a
charging boss was unreadable — the boss IS the level.

Wall-stick bug: the player's position was clamped at the walls but the velocity
was not, so holding a direction into a wall parked the octopus with vx at
-MAX_SPEED, and letting go left friction and the opposite input a full second of
work before anything moved. It read as being stuck to the wall. The into-wall
component is now zeroed (not reflected — a bounce reads as losing control too).
Pointer targets are clamped into the play area for the same reason: a finger
dragged into the HUD band used to leave the octopus pushing at the ceiling.

Cost, because this is an easter egg and not an engine:
  - the loop stops entirely when the panel is off-screen (IntersectionObserver)
    or the tab is in the background, and resumes with a fresh clock;
  - the resting panel runs at half rate, carrying skipped time forward so the
    drift keeps its speed rather than halving;
  - the wash and vignette gradients are built once per box/theme/level instead of
    per frame;
  - particulate down from up to 60 motes to 40, shafts from 4 to 3.
Bundle: 11 350 -> 14 486 bytes for the whole component, no new dependencies, still
no sprite assets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…etails

A guide-tester pass over the deployed preview (Playwright, four viewports, both
themes, reduced motion) found two real bugs and a set of accessibility details.

Search prefill was garbage on exactly the URL that started this. On
/overview/404.html the suggestions component published the raw last segment as
the search query, so the page's primary call to action opened Pagefind on
"404.html" — eight irrelevant hits — confirming the "this page is broken" reading
it exists to dispel. The fallback query is now sanitised the same way 404.astro
already sanitised its own: extension stripped, pure-numeric and 404-like segments
dropped, and nothing published rather than noise, which leaves the modal empty and
focused. Verified: /overview/404.html publishes no query and opens empty;
/storage/data-strems/ still publishes "Data Streams".

Arrow keys stopped scrolling the page while a game ran off-screen. Start a game,
scroll down to the recovery links, press an arrow: nothing moved, with no visible
explanation, because the "Esc to leave" hint is hidden during play. The keyboard
gate now also requires the panel to be in the viewport, measured with
getBoundingClientRect at the moment the key arrives rather than read from the
IntersectionObserver — the observer is asynchronous, carries an 80px margin and
does not fire at all in a background tab, three ways to get this wrong. One layout
read per movement keydown is nothing next to the frame being drawn anyway.
Verified: panel fully above the viewport, state still playing, ArrowDown not
prevented; scrolled back, prevented again; Esc still exits from anywhere.

Accessibility and touch, all from the same report:
  - the hint line measured 3.71:1 on the light theme's water (AA wants 4.5 for
    12.5px), and it is the only visible statement of the controls and the exit —
    now theme white at 0.85;
  - the "this is an optional game" note moved ahead of the controls it excuses and
    is tied to the Play button with aria-describedby, so it is announced with the
    button rather than only in reading order;
  - start() put focus on <body> because the button it was on hides itself; focus
    now goes to the panel (tabindex="-1"), not to Stop, where a habitual space
    press would quit the game it just started;
  - Stop keeps its 43x21 look but gets a 44px tap target through a ::before hit
    area — on a phone it is the only way out, since there is no Esc and
    drag-to-swim means the field edges are not an exit;
  - below 600px the hint no longer disappears: it swaps to "Drag to swim · tap
    Stop to leave", one line either way, so a phone is still told how to move and
    how to leave;
  - the loop's sleep() drops held keys, so waking up cannot resume with a stuck
    arrow.

Not addressed here, worth their own change: the suggestion index holds canonical
slugs only, so /transformations/pyhton/ misses Python Transformation (a redirect
alias) and prefills the search with "dbt Transformation"; and eight duplicate-title
pairs (extractor vs writer PostgreSQL, and so on) can rank the wrong one first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All four notes from the designer, with two of them adjusted rather than taken
literally — recorded here because both adjustments were deliberate.

Two buttons instead of one primary. "Search the documentation" becomes Home and
Search side by side: they are the only two moves that always work on this page,
and neither is obviously the likelier one. Home is a real <a href="/"> so
middle-click and open-in-new-tab behave; Search keeps the id the delegated handler
in 404.astro binds to. One shape, two weights — two filled accent buttons side by
side fight each other. Both carry `not-content`, because Starlight's prose link
rule outranks a plain class and painted the Home label accent-blue on the
accent-blue fill: an empty blue rectangle.

The lede loses "Here's what's nearby." — the block underneath already is what's
nearby.

The failed address stays, but stops shouting. Michal asked to delete "There's no
page at /overview/404.html"; not naming the address is exactly what led him to
read a working link as broken, so instead of dropping it the line merges with the
guesses lead into one quiet grey sentence: "/storage/data-strems/ · did you mean:",
and "· nothing matches that address" when nothing scores. Starlight's inline-code
badge is stripped there for the same reason the buttons needed `not-content`.

The game moves to the top, under the title and the lede, and gets air on both
sides (1.5rem/2rem). This is the designer's call and it costs the recovery block
real space: at 1280x800 the suggestions now start at 463 instead of 218, the
buttons at 634, the links heading at 714 and its first link at 755 — right at the
fold instead of comfortably above it. The compact resting panel (~215px) is what
keeps that affordable, and the full field is still only taken while somebody is
playing.

Also: the "Documentation home" bullet is gone from the list, since the Home button
is the same link; and the wording that described the old order — the file header,
the sr-only note, the CSS section comment — is corrected.

Verified locally in a production build, both themes: /storage/data-strems/ shows
the quiet lead, three guesses, the parent link and both buttons; /zzz/qqq/ shows
"nothing matches that address" with no guesses; /404.html hides the block, keeps
both buttons and opens search empty; Home resolves to / and the recovery list now
starts at connection.keboola.com; Play grows the field to 464px inside the
viewport with no jump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Michal's note offered "a bit of spacing or right at the top" and we took the top;
seen in place, it puts the octopus ahead of everything that actually recovers the
visit. The panel now follows the Home/Search buttons and precedes the fallback
link list, which is the order the page's job implies: failed address, closest
pages, the two exits, and only then the thing to do if none of that helped. The
spacing Michal asked for stays (1.5rem above, 1.75rem below).

Measured at a 800px-tall viewport, and this is the point of the move: suggestions
at 215, buttons at 387, panel 459-675, links heading 707, first link 748. With the
game first those were 463 / 634 / 215 / 714 / 755 — the recovery block used to
start halfway down the screen and now sits well above the fold, while the link list
lands in the same place either way.

The comments and the screen-reader note that described the old order are corrected
with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playtesting (Michal, repeatedly) ended the same way every time: level 10, all
three lives gone, nobody ever saw the level-15 boss. The numbers explain it. Enemy
speed climbed linearly, 1.7 + 0.28 per level, so drifters passed the player's own
cap of 7 around level 20 — late levels were not hard, they were unplayable, with
nothing to dodge to. The crowd hit its ceiling of 11 at level 9 and stayed there.
Lives were fixed at three with no way to earn one back. And after a hit the player
always respawned at the same spot with 1.2s of grace, which on a crowded level
means landing right back in the crowd that just hit you — the remaining lives went
in a couple of seconds.

Four changes, aimed at "you can keep playing", not at "it is easy now":

  - Speed becomes asymptotic, 1.7 + 3(1 - e^-(level-1)/6), approaching 4.7 and
    staying well under the player's 7. Pressure past the plateau comes from
    density and from the 504 chasers, which is pressure a player can answer.
  - The crowd grows every other level and stops at 9 instead of 11.
  - A life back every fifth level, capped at five, announced on the level banner
    ("Level 10 · +1 life"). Three at the start, four from level 5, five from 10.
  - Respawn picks the emptiest of six candidate spots — the one whose nearest
    enemy is furthest — instead of a fixed corner, and grace goes 1.2s -> 1.5s.

The curve, old -> new (speed / enemies / lives):
  lv  5   2.82 / 7  / 3   ->   3.16 / 6 / 4
  lv  9   3.94 / 11 / 3   ->   3.91 / 9 / 4
  lv 10   4.22 / 11 / 3   ->   4.03 / 9 / 5
  lv 15   5.62 / 4  / 3   ->   4.41 / 4 / 5
  lv 19   6.74 / 3  / 3   ->   4.55 / 3 / 5
  lv 23   7.86 / 3  / 3   ->   4.62 / 3 / 5

Early levels come out a touch faster than before (2.55 vs 2.26 on level 3) with
one fewer enemy on the field, which is a wash. The boss stays on 15 — the point
was to make 15 reachable, not to move it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drag-to-swim works, but on a phone the finger you steer with sits on top of the
water you are trying to read. So: four arrows in the bottom-left corner, over the
water, translucent — shown only on coarse pointers and only while a game runs.

They fill the same `keys` set the keyboard fills, so nothing downstream knows the
difference and two buttons at once come out as a diagonal for free. Each button
captures its own pointer on press, so a thumb that slides off still releases the
direction, and the press is preventDefault-ed to stop it becoming a page scroll or
a double-tap zoom. Dragging stays: it is still the better control with a mouse.

Real <button>s with aria-labels rather than canvas-drawn hit zones — and because
the pad is `display: none` outside touch, its four buttons never enter the tab
order on desktop, where the arrow keys already do the job.

40x40 each, 130x85 in total: 18% of the field's width, 3.3% of its area, in the
corner furthest from where anything usually happens, and it does not overlap Stop.
The mobile hint line now reads "Arrows or drag to swim · tap Stop to leave".

Verified: presses are taken by the game while playing and ignored while idle (so
the pad can never eat a tap meant for something else); geometry and offsets as
above; the CSS gate ships as a `(hover: none) and (pointer: coarse)` rule and the
pad computes to `display: none` on desktop. Not verified on a real phone — no
device in this session, and the emulator route only proves the media query.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jordanrburger
jordanrburger enabled auto-merge August 5, 2026 16:33
@jordanrburger
jordanrburger merged commit 2be9a7f into main Aug 5, 2026
2 of 3 checks passed
@jordanrburger
jordanrburger deleted the docs/404-page-inkdash branch August 5, 2026 17:35

This branch was successfully deployed

1 active deployment
Preview — d501459f Deployed Aug 5, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants