diff --git a/AGENTS.md b/AGENTS.md
index 7ff5c3d..cbe783c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -296,3 +296,21 @@ Every call prints a usage line to stderr and appends to
- **The free tier can run out mid-task.** A 402 means the shared credit
pool is empty and will not recover today; a 429 with `free_tier_quota`
resets at 00:00 UTC. Neither is worth retrying — switch to a key.
+
+## Working on this repo
+
+Everything above is about using the tool. If you are changing it:
+
+- `make check` before proposing anything. It builds, vets, runs the Go
+ tests, and checks the rate card, the docs corpus, the gateway and the
+ site.
+- **Touching `site/`?** Read `DESIGN.md` first — it is the material
+ contract: the tokens, the two gestures the page is built on, and the
+ responsive rules. `TASTE.md` holds prior rulings, each with the reason
+ it was rejected; read it before re-proposing something it already
+ refused. Run `site/bans.sh` before you ship (`make site-check` does).
+- The site's HTML is generated. Edit `site/build.py`, not the committed
+ `index.html` files, and rerun `python3 site/build.py`.
+- Visual changes get looked at in a browser at more than one width before
+ they are called done. The stylesheet has three breakpoints and a
+ document that must never scroll sideways.
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..cacbca1
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,117 @@
+# DESIGN — the material
+
+Read this before touching anything under `site/`. `TASTE.md` holds prior
+rulings — the things we tried and rejected, with why. This file holds the
+material: the tokens, the laws they follow, and the two gestures the site
+is built around.
+
+The test for any change here: **a change that makes the surface prettier
+and the task harder must fail.** The task on this site is reading
+documentation.
+
+If the material changes, rewrite this file in the same commit. A contract
+that lies about the code is worse than no contract.
+
+## Surfaces
+
+| Surface | Class | Primary task | Protected functions |
+| --- | --- | --- | --- |
+| `/` overview | brand | decide whether to install this | the pitch, the badges, the four buttons, the first terminal transcript |
+| `install`, `commands`, `formats`, `cost`, `agents` | product | find one command or one flag and copy it | tables and their third column, `.term` transcripts, inline `code`, anchors, the pager |
+| `news` | product | see what changed and when | dates, version numbers, links out |
+| `playground` | product | send a real request without a key | the prompt field, Send/Stop, the transcript, error text, quota readout |
+| `404` | product | get somewhere real | the links |
+
+Brand moves applied to product surfaces are the failure mode to watch.
+Everything below the fold on the overview page is a product surface
+wearing the brand page's clothes.
+
+## The material
+
+No framework, no build step for the reader, no JavaScript required to
+read anything. The generator (`site/build.py`) is stdlib Python; the page
+is one stylesheet and two scripts that only ever upgrade something already
+on the page.
+
+| Dimension | Value | Law |
+| --- | --- | --- |
+| Colour | one token block on `:root` in `style.css` | Every colour is a custom property, declared twice: the dark value alone, then the `light-dark()` pair. A browser without `light-dark()` keeps the dark value and the site still works. No colour anywhere else in the stylesheet. |
+| Themes | `color-scheme: light dark`, toggle pins `only dark` / `only light` | The default follows the OS. The toggle is the word `theme: auto` cycling auto → light → dark, because two icons cannot express three states. |
+| Accents | `--cyan` `--pink` `--purple` `--yellow` | Not shared between themes: the dark set is tuned for black and every one of them fails 4.5:1 on paper, so the light values are separately measured equivalents. |
+| Type | JetBrains Mono 300/400/700, monospace fallback stack | One face. The whole identity rests on it — a proportional face here would be a different product. |
+| Measure | `--measure: 68ch` inside a `62rem` wrap | The margin this leaves on a wide screen is where the depth gauge lives; nothing else goes there. |
+| Radius | `3px`, plus `50%` for actual circles | One corner language. |
+| Motion | `--dur-fast: 140ms`, `--dur-slow: 320ms`, `--ease: cubic-bezier(0.25, 0, 0.2, 1)` | Two durations and one curve for every transition. Fast answers the pointer; slow follows the theme or moves something. |
+| Terminal blocks | `.term` carries a fixed palette and `color-scheme: only dark` | A picture of a terminal does not invert when the page does. `@media print` is the one exception — on paper it is text, not a screen. |
+| Sea | `--sea-0`..`--sea-3`, `--sea-deep`, `--sea-abyss`, `--sea-foam`, `--whale*`, `--sky-star*` | `waves.js` paints by resolving these off `:root`, so both themes stay in the stylesheet and the toggle just works. Colour literals in that file are a defect. |
+
+## The two gestures
+
+The tool is named for two verbs and the page performs both. They are the
+only motion on the site that is not a hover state, and both answer
+something the reader did — nothing here moves at a reader sitting still
+except the water itself, which is scenery.
+
+**Deep — scrolling descends.** One scalar, `scroll.depth`, is scroll
+position over the scrollable range. `waves.js` owns it, publishes it to
+CSS as `--depth` on `:root`, and every part of the descent reads that one
+number: the surface climbs out of the frame with parallax, the stars go
+out over the first eighth, marine snow streams upward, the water darkens
+toward `--sea-abyss`, and the whale sinks more slowly than the surface so
+it is still there — dim — when you are in the dark. The gauge in the right
+margin is the same number as text.
+
+**Seek — clicking pings.** A click sends two things: a wave packet into
+the water (what the water does) and a sonar ring from the pointer (what
+you did). When the ring reaches the whale, the whale's outline answers and
+fades. Down deep that echo is the only time you see it clearly, which is
+the argument for the whole mechanism. Clicks on controls get the same
+pulse from the control's own edge, in CSS, so the page has one click
+language rather than one for scenery and one for buttons.
+
+Both stop completely under `prefers-reduced-motion`: the sea paints one
+still frame, no ping is emitted at all (a ring that cannot travel is a
+circle left on the page), and no control pulses.
+
+## Responsive
+
+Four breakpoints, each answering a different question — which is why they
+are not one number:
+
+- **≥1100px** — the depth gauge appears in the right margin. It is the
+ only thing that ever goes there.
+- **≤52rem** — the masthead stacks: brand row, then one horizontally
+ scrolling nav strip that bleeds to both edges with a mask fade as its
+ affordance. This is arithmetic, not comfort — brand, nav and toggle need
+ about 760px of row between them. The theme toggle lifts out of the strip
+ onto the brand row, because a preference control you have to scroll to
+ find is the one control on the site you cannot see. The masthead is
+ sticky, so a row saved here is a row saved on every screenful.
+- **≤36rem** — tables get the same edge fade, because a table narrower
+ than its `34rem` minimum hides the column carrying the actual
+ explanation and puts the scrollbar thirteen rows below the fold.
+- **≤640px** — type drops to 14px. Reading comfort, nothing structural.
+- No page ever scrolls horizontally. `.term` blocks and tables scroll
+ inside themselves; the document does not. This is checkable and worth
+ checking after any layout change.
+
+## Hard bans
+
+`site/bans.sh`, wired into `make site-check`. Each one encodes a scar, not
+a preference; the why is in `TASTE.md` or beside the code:
+
+1. A hardcoded colour outside the token block.
+2. A colour literal in `waves.js` outside a `var()` fallback.
+3. A corner radius outside `3px` / `50%` / `inherit`.
+4. A second font family.
+5. An urgency animation — pulse, blink, bounce, flash, shake.
+6. A missing `prefers-reduced-motion` block.
+
+## Behavioral floor
+
+Never traded away for a surface: text readable with JavaScript off, every
+colour pair above 4.5:1 against its background in both themes, controls
+that name their state rather than implying it with an icon, and a document
+that does not move sideways. When a change is proposed, judge behaviour
+before surfaces and count what it changes — a redesign that improves the
+screenshot and costs a reader the third column of a table has lost.
diff --git a/Makefile b/Makefile
index 63b47c1..d5ad1f3 100644
--- a/Makefile
+++ b/Makefile
@@ -100,8 +100,9 @@ price-check:
# generator has its own --check; these cover the parts it cannot see —
# that the playground's script and markup still agree, that all three
# implementations of the enrolment puzzle still produce the same answers,
-# and that the sea still runs, stops when it should, and draws the same
-# whale on the canvas as whale.svg draws for readers without JavaScript.
+# that the sea still runs, stops when it should, and draws the same whale
+# on the canvas as whale.svg draws for readers without JavaScript, and
+# that the design rules in DESIGN.md still describe the stylesheet.
.PHONY: site-check
site-check:
python3 site/build.py --check
@@ -110,6 +111,7 @@ site-check:
node site/playground.dom.test.js
node site/waves.test.js
node site/waves.dom.test.js
+ ./site/bans.sh
.PHONY: site
site:
diff --git a/TASTE.md b/TASTE.md
index 5d53d4c..f24d91f 100644
--- a/TASTE.md
+++ b/TASTE.md
@@ -5,12 +5,66 @@ with its why — a rule without a why fossilizes into style police. Read
this before any design verdict; delete a scar when its expiry condition
arrives.
+The material these rulings are about is in `DESIGN.md`.
+
The test behind all of them: a surface that gets prettier while the task
gets harder is a costume, and it fails no matter how clean it looks in a
screenshot.
---
+## 2026-08-07 rejected: rebuilding the sea in three.js
+
+**Why.** The obvious way to make a page about depth feel deep is WebGL: a
+real volume, real caustics, real particles, and three.js is right there.
+The cost is not the frame budget, it is everything the site currently is.
+`waves.js` has no dependencies and no build step, it reads every colour it
+paints from a CSS custom property so both themes live in the stylesheet,
+its star and snow fields are seeded so a rendered frame is reproducible
+and testable, and the whole thing degrades to a CSS gradient and an SVG
+whale with JavaScript off. three.js is ~600KB before a line of ours, needs
+a bundler on a site whose pitch includes not having one, cannot read the
+theme without a colour bridge written by hand anyway, and turns a
+deterministic frame into something no test can assert about. It would buy
+a better-looking sea and sell the reasons the sea is good.
+
+**Reuse.** The 2D canvas already had the right substrate — four sine
+layers with a nearness gradient, a whale reading its height out of the
+same field. Depth came from making those layers respond to scroll
+position, which is arithmetic on numbers that were already there: about
+sixty lines, no new dependency, and the descent is testable because the
+fields are still seeded. Parallax, an abyss veil and marine snow do the
+job WebGL was wanted for, at a few hundred sine calls a frame.
+
+**Expires.** If the site ever grows something that genuinely needs a
+scene graph — a 3D model of something, real lighting — this is worth
+revisiting for that thing, on that page, not for the background.
+
+---
+
+## 2026-08-07 rejected: a scroll-position effect that is only an effect
+
+**Why.** "Scrolling should feel like going deeper" is a mood, and the
+first version of it was exactly that: the water darkened as you scrolled
+and nothing else was true. It photographs well and it tells the reader
+nothing they did not already know — which is the definition of the costume
+this file exists to catch. On a docs page long enough to want the effect,
+the reader's actual question is *how much of this is left*, and the native
+scrollbar autohides on every platform that matters.
+
+**Reuse.** The descent publishes one number, `--depth`, and the same
+number is legible as text in the margin the 68ch measure was already
+wasting. It is the scroll position — the thing the scrollbar shows — said
+in the page's own vocabulary and left on screen. That is the difference
+between an effect and an instrument: the effect makes you feel deep, the
+instrument tells you how deep. We shipped the second one and got the first
+for free.
+
+**Expires.** If the gauge ever has to move over the text column to fit, it
+has stopped being free and should be deleted rather than shrunk.
+
+---
+
## 2026-08-05 rejected: one `ask` verb with `--api` to pick the format
**Why.** DeepSeek's four wire formats all answer "talk to the model", so
diff --git a/site/404.html b/site/404.html
index 536cca4..2cb9da1 100644
--- a/site/404.html
+++ b/site/404.html
@@ -60,7 +60,15 @@
> thevibeworks/ deepseek-cli
-
+
+
diff --git a/site/agents/index.html b/site/agents/index.html
index f07f2d5..1b091ab 100644
--- a/site/agents/index.html
+++ b/site/agents/index.html
@@ -47,6 +47,7 @@
+0 m
skip to content
diff --git a/site/bans.sh b/site/bans.sh
new file mode 100755
index 0000000..8bec4e8
--- /dev/null
+++ b/site/bans.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+# Design bans for the site — deterministic checks that encode scars.
+#
+# These are not style preferences. Each one is a rule that broke something
+# real, written down so it cannot break it again; the why for each lives in
+# TASTE.md and DESIGN.md. A clean run is evidence, not proof — it cannot
+# see costume, so pair it with a look at the actual page.
+#
+# site/bans.sh # check
+#
+# Exit 1 if any ban trips.
+
+set -uo pipefail
+
+cd "$(dirname "$0")" || exit 1
+fail=0
+
+hit() { echo "DEFECT [$1]: $2"; fail=1; }
+
+# 1. Colour lives in the token layer.
+#
+# Every colour on the site is a custom property on :root, declared once
+# for dark and once as a light-dark() pair. That is what lets waves.js
+# paint the sea by reading CSS, and what makes the theme toggle one
+# property rather than a second stylesheet. A hex further down the file
+# is a colour the other theme cannot reach.
+#
+# Three exemptions, all deliberate and all documented where they sit:
+# the .term and .pg-command palettes (a picture of a terminal does not
+# invert when the page does), the @media print block (paper is paper),
+# and mask-image stops (#000 there means "opaque", not a colour).
+#
+# Interval expressions and \b are not portable across awk flavours, so
+# the hex match is spelled out longhand — a ban that silently matches
+# nothing is worse than no ban, and this one did exactly that until it
+# was tested against a deliberate violation.
+out=$(awk '
+ /^:root/ { intoken = 1 }
+ /^}/ { if (intoken) intoken = 0 }
+ /--term-/ || /@media print/ || /mask-image/ { next }
+ /^ *background: #fff/ || /^ *color: #000/ { next }
+ /^ *\*/ { next }
+ !intoken && /#[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]/ { print FILENAME ":" NR ": " $0 }
+' style.css)
+if [ -n "$out" ]; then
+ hit token "hardcoded colour outside the token block"
+ echo "$out" | head -20
+fi
+
+# 2. The sea's colours are CSS's business, not JavaScript's.
+#
+# waves.js resolves every colour it paints with through a CSS custom
+# property, which is the only reason the two themes are settled in one
+# place. Colour literals in there are allowed in exactly one shape: the
+# var() fallbacks inside readPalette, which exist so a stylesheet
+# missing a token degrades to something rather than to `undefined`.
+if out=$(grep -nE "#[0-9a-fA-F]{3,8}\b|rgba?\([0-9]" waves.js |
+ grep -vE "read\(|^\s*[0-9]+:\s*//|fallback"); then
+ hit js-colour "colour literal in waves.js outside a token fallback"
+ echo "$out" | head -20
+fi
+
+# 3. One corner language.
+#
+# 3px everywhere, 50% for the things that are actually circles, and
+# `inherit` for a pseudo-element tracing its own parent. A fourth value
+# is a second design speaking over the first.
+if out=$(grep -nE 'border-radius:' style.css | grep -vE ':\s*(3px|50%|inherit|0)\s*;'); then
+ hit radius "a corner radius outside the one language (3px / 50% / inherit)"
+ echo "$out" | head -20
+fi
+
+# 4. One face.
+#
+# The site is set in JetBrains Mono with a monospace fallback stack, and
+# that single choice is doing a lot of the identity work. A second family
+# is a decision that should be argued for in TASTE.md first.
+if out=$(grep -nE "font-family:" style.css | grep -vE "JetBrains Mono|inherit"); then
+ hit font "a second font family"
+ echo "$out" | head -20
+fi
+
+# 5. No urgency devices.
+#
+# Pulsing, blinking and bouncing spend the reader's trust to buy their
+# attention. This site's motion answers something the reader did — the
+# pointer, a click, the scroll position — and there is no animation on
+# it that runs at a reader who is sitting still. The sea is the one
+# exception and it earns it by being scenery, off-screen-aware, and
+# stopped entirely under prefers-reduced-motion.
+if out=$(grep -nE "animation:.*(pulse|blink|bounce|flash|shake)" style.css); then
+ hit urgency "an urgency animation"
+ echo "$out" | head -20
+fi
+
+# 6. Motion asks permission.
+#
+# Anything that moves has to have an answer for a reader who asked the
+# OS not to. The global reduce block is that answer for transitions and
+# animations; this checks it is still there, because deleting it is a
+# one-line accident with a whole-site blast radius.
+if ! grep -q "prefers-reduced-motion" style.css; then
+ hit reduced-motion "style.css has no prefers-reduced-motion block"
+fi
+
+if [ "$fail" -eq 0 ]; then
+ echo "bans: clean"
+fi
+exit "$fail"
diff --git a/site/build.py b/site/build.py
index 3061697..a174368 100644
--- a/site/build.py
+++ b/site/build.py
@@ -119,7 +119,18 @@ def head(*, slug, title, description, keywords, jsonld, crumb_title,
# that asks for the band: the 404 carries its sea as a strip under the
# masthead, so the whale rides the header rule like a horizon and the
# wayfinding links below it keep their contrast.
- sea_fixed = "" if band_sea else '
\n'
+ # The depth gauge rides with the fixed sea and only with it. It reads
+ # how far down the water column you have scrolled, and a band page has
+ # no column to descend -- a gauge there would be an instrument wired to
+ # nothing. waves.js treats it as optional for the same reason.
+ sea_fixed = (
+ ""
+ if band_sea
+ else '
\n'
+ ''
+ ' '
+ '0 m
\n'
+ )
sea_band = '
' if band_sea else ""
return f"""
@@ -168,12 +179,21 @@ def head(*, slug, title, description, keywords, jsonld, crumb_title,
> thevibeworks/ deepseek-cli
-
+
+
{sea_band}
diff --git a/site/commands/index.html b/site/commands/index.html
index 410c534..3f5e652 100644
--- a/site/commands/index.html
+++ b/site/commands/index.html
@@ -47,6 +47,7 @@
+0 m
skip to content
diff --git a/site/cost/index.html b/site/cost/index.html
index 48df473..810aac9 100644
--- a/site/cost/index.html
+++ b/site/cost/index.html
@@ -47,6 +47,7 @@
+0 m
skip to content
diff --git a/site/formats/index.html b/site/formats/index.html
index 0870dbe..7017b24 100644
--- a/site/formats/index.html
+++ b/site/formats/index.html
@@ -38,6 +38,7 @@
+0 m
skip to content
diff --git a/site/index.html b/site/index.html
index a019d37..5e4396b 100644
--- a/site/index.html
+++ b/site/index.html
@@ -54,6 +54,7 @@
+0 m
skip to content
diff --git a/site/install/index.html b/site/install/index.html
index 01faa12..17c6f8a 100644
--- a/site/install/index.html
+++ b/site/install/index.html
@@ -47,6 +47,7 @@
+0 m
skip to content
diff --git a/site/news/index.html b/site/news/index.html
index 87d14a3..1ad96eb 100644
--- a/site/news/index.html
+++ b/site/news/index.html
@@ -38,6 +38,7 @@
+0 m
skip to content
diff --git a/site/playground/index.html b/site/playground/index.html
index 0c3537e..458fd8e 100644
--- a/site/playground/index.html
+++ b/site/playground/index.html
@@ -47,6 +47,7 @@
+0 m
skip to content
diff --git a/site/style.css b/site/style.css
index 13c299a..535850e 100644
--- a/site/style.css
+++ b/site/style.css
@@ -103,6 +103,31 @@
--sea-glow-fade: rgba(0, 194, 233, 0);
--sea-glow-fade: light-dark(rgba(10, 110, 133, 0), rgba(0, 194, 233, 0));
+ /* The colour of being *in* the water rather than looking at it, washed
+ * over the whole frame as the page descends. --sea-deep is the floor
+ * of a layer's gradient; this is the dark you are inside.
+ *
+ * Dark goes almost to black with a little blue left in it — the point
+ * of an abyss is that there is nearly nothing.
+ *
+ * Light cannot follow it down, and the limit is arithmetic rather than
+ * taste: at full depth this colour *is* the page background under the
+ * prose, so the palest text on the site has to survive it. --muted
+ * (#5a5a60, relative luminance 0.111) needs a background at 0.674 or
+ * above to hold 4.5:1, and this sits at 0.684. Anything moodier fails
+ * the footer. On paper the descent is therefore a change of hue and a
+ * little value, not a fade to black — which is also what actually
+ * happens when you look down into clear water in daylight. */
+ --sea-abyss: #00060c;
+ --sea-abyss: light-dark(#c2dce8, #00060c);
+
+ /* How far down the page you are, 0 at the top and 1 at the bottom.
+ * waves.js writes it on every scroll and the sea reads the same
+ * number, so the margin and the water can never disagree. Declared
+ * here so anything reading it has a value before the first scroll —
+ * and with JS off it simply stays 0. */
+ --depth: 0;
+
/* The whale is a silhouette, so it only has to be darker than the
* water on paper and lighter than the page on black. */
--whale: #123f5c;
@@ -267,6 +292,17 @@ body::before {
color: var(--pink);
}
+/* The controls end of the masthead: the nav and the theme toggle, which
+ * wrap together as one group. They are two elements rather than one so
+ * the phone breakpoint can mask the nav into a scrolling strip without
+ * masking the toggle out of existence. */
+.bar-end {
+ display: flex;
+ align-items: center;
+ gap: 1.1rem;
+ flex-wrap: wrap;
+}
+
.sitenav {
display: flex;
gap: 1.1rem;
@@ -792,6 +828,140 @@ pre code {
}
}
+/* ---------- the depth gauge ---------- */
+
+/* How far down the page you are, in the metaphor the page is already in.
+ *
+ * It is scroll position — the same thing the scrollbar shows — so the
+ * question is what it adds. Two things. On a long docs page the native
+ * scrollbar is a hairline that autohides on every platform that matters,
+ * and this does not; and it puts the number in the one place a monospace
+ * site has going spare, which is the margin the 68ch measure leaves
+ * empty. It is the descent made legible rather than only felt.
+ *
+ * It lives in that margin and nowhere else: below 1100px there is no
+ * margin to live in, and a gauge over the text would be exactly the
+ * costume this site's TASTE.md exists to reject. It is aria-hidden
+ * because a screen reader already knows where it is on the page, and
+ * because "-240 m" is not a fact about the document.
+ */
+.gauge {
+ position: fixed;
+ /* The rail hugs the right edge of the window and the reading hangs off
+ * its left, into the margin. Anchoring the other way round put the
+ * number outside the viewport on a 1100px window and gave the whole
+ * document a horizontal scrollbar — a decoration that moves the page
+ * sideways has failed at being a decoration. */
+ right: max(0.75rem, calc((100vw - 62rem) / 2 - 5.5rem));
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 3;
+ display: none;
+ pointer-events: none;
+ font-size: 0.7rem;
+ letter-spacing: 0.04em;
+ color: var(--muted);
+ /* Invisible at the surface, present in the deep. The hero is the one
+ * screen on the site that should carry nothing but the pitch. */
+ opacity: calc(0.35 + var(--depth) * 0.65);
+ transition: opacity var(--dur-slow) var(--ease);
+}
+
+@media (min-width: 1100px) {
+ .gauge {
+ display: block;
+ }
+}
+
+.gauge-rail {
+ position: relative;
+ display: block;
+ width: 1px;
+ height: 34vh;
+ background: var(--line);
+}
+
+/* The filled part is how far you have come. It runs top-down because the
+ * descent does. */
+.gauge-fill {
+ position: absolute;
+ inset: 0 0 auto 0;
+ display: block;
+ height: calc(var(--depth) * 100%);
+ background: linear-gradient(180deg, var(--sea-2), var(--sea-3));
+}
+
+/* The reading rides the head of the fill rather than sitting still beside
+ * it — a number in a fixed spot is a label, a number that descends is the
+ * instrument. Rotated so it reads along the rail and takes no width from
+ * the measure. */
+.gauge-read {
+ position: absolute;
+ right: 0.55rem;
+ top: calc(var(--depth) * 100%);
+ transform: translateY(-50%);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+ color: var(--cyan);
+}
+
+@media print {
+ .gauge {
+ display: none;
+ }
+}
+
+/* ---------- seek ---------- */
+
+/* A click on a control sends the same pulse out that a click on the page
+ * sends into the water. One gesture, two materials: canvas out there,
+ * a pseudo-element in here.
+ *
+ * It is on ::after so it never disturbs layout, `inset: -1px` so it
+ * starts on the control's own edge, and it inherits the border radius so
+ * it is the shape of the thing you pressed rather than a circle
+ * approximating it. `overflow` is deliberately not clipped — the pulse
+ * leaving the control is the whole point.
+ */
+@keyframes seek-out {
+ from {
+ opacity: 0.55;
+ transform: scale(1);
+ }
+
+ to {
+ opacity: 0;
+ transform: scale(1.5);
+ }
+}
+
+.btn,
+.sitenav a,
+.themetoggle,
+.pager a,
+.pg-primary {
+ position: relative;
+}
+
+.is-seeking::after {
+ content: '';
+ position: absolute;
+ inset: -1px;
+ border: 1px solid var(--cyan);
+ border-radius: inherit;
+ pointer-events: none;
+ animation: seek-out 620ms var(--ease) forwards;
+}
+
+/* Reduced motion gets no pulse at all rather than a fast one. waves.js
+ * already declines to add the class; this is the belt to that braces, and
+ * it is also what a printed page needs. */
+@media (prefers-reduced-motion: reduce) {
+ .is-seeking::after {
+ display: none;
+ }
+}
+
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
@@ -854,6 +1024,22 @@ pre code {
background: color-mix(in srgb, var(--bg) 72%, transparent);
}
+/* A table narrower than its 34rem minimum scrolls sideways, and on a
+ * phone the scrollbar that says so is at the bottom of thirteen rows —
+ * so the column carrying the actual explanation is invisible until you
+ * have already scrolled past the thing you were trying to read. The same
+ * fade the nav strip uses says it at the edge instead, where the missing
+ * content is. Both are horizontal scrollers; they should say so the same
+ * way.
+ *
+ * Only below the width where the table fits: a fade over a table with
+ * nothing beyond it is a lie about there being more. */
+@media (max-width: 36rem) {
+ .tablewrap {
+ mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 2rem), transparent 100%);
+ }
+}
+
table {
border-collapse: collapse;
width: 100%;
@@ -1066,19 +1252,86 @@ figure.shot figcaption {
outline-offset: 2px;
}
-@media (max-width: 640px) {
- body {
- font-size: 14px;
- }
-
+/* The masthead breaks before the type does, and the two are separate
+ * questions. Type size is about reading comfort on a small screen (640px).
+ * This is about arithmetic: brand, nav and toggle need about 760px of row
+ * between them, so anything narrower than 52rem stacks them — and at 768px
+ * the old single breakpoint left a sticky three-row masthead eating a
+ * seventh of a tablet screen on every scroll. */
+@media (max-width: 52rem) {
.masthead .wrap {
flex-direction: column;
- align-items: flex-start;
- gap: 0.5rem;
+ align-items: stretch;
+ /* `nowrap` is load-bearing, not tidying. A wrapping column flex
+ * container sizes each line to its own contents, so the nav below
+ * — one row wider than the screen by design — made a 794px line,
+ * stretched nothing, and gave the whole document a horizontal
+ * scrollbar. One line, and the nav is constrained to the wrap and
+ * scrolls inside itself as intended. */
+ flex-wrap: nowrap;
+ align-content: stretch;
+ gap: 0.45rem;
+ padding-top: 0.6rem;
+ padding-bottom: 0.6rem;
}
+ /* Nine links wrapped onto three rows cost a phone about a fifth of its
+ * screen, and the masthead is sticky, so it costs that fifth on every
+ * screenful — before a word of the page. One scrolling row instead:
+ * the same links, in the same order, at a height that does not
+ * negotiate.
+ *
+ * The gutter trick (negative margin plus matching padding) lets the
+ * strip bleed to both edges while its first and last items still line
+ * up with the text column, so it reads as part of the page rather
+ * than a component sitting on it. */
.sitenav {
- gap: 0.85rem;
+ gap: 1rem;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ margin: 0 calc(var(--gutter) * -1);
+ padding: 0 var(--gutter) 0.15rem;
+ scrollbar-width: none;
+ -webkit-overflow-scrolling: touch;
+ /* The fade is the affordance: it says there is more to the right
+ * without spending a row on an arrow. It has to be transparent at
+ * the ends rather than the theme colour, or it becomes a hard
+ * edge over the water. */
+ mask-image: linear-gradient(90deg, transparent 0, #000 var(--gutter), #000 calc(100% - 1.5rem), transparent 100%);
+ }
+
+ .sitenav::-webkit-scrollbar {
+ display: none;
+ }
+
+ /* The group stops being a row and becomes the strip alone; the toggle
+ * leaves the flow entirely, just below. */
+ .bar-end {
+ display: block;
+ }
+
+ .sitenav a {
+ white-space: nowrap;
+ flex: none;
+ }
+
+ /* Onto the brand row, where there is space going spare, rather than
+ * below the nav strip where it would cost a third row. */
+ .themetoggle {
+ position: absolute;
+ top: 0.5rem;
+ right: var(--gutter);
+ white-space: nowrap;
+ }
+}
+
+@media (max-width: 640px) {
+ body {
+ font-size: 14px;
+ }
+
+ main {
+ padding-top: 1.75rem;
}
}
@@ -1402,12 +1655,20 @@ figure.shot figcaption {
}
/* The command panel. This is the page's argument, so it gets the
- * terminal treatment rather than looking like another field. */
+ * terminal treatment rather than looking like another field — and it
+ * takes the .term palette to do it rather than three copies of the same
+ * hex values, so a change to what a terminal looks like lands here too.
+ * Redeclared rather than inherited because this element is not inside a
+ * .term. */
.pg-command {
+ --term-bg: #0d0d0d;
+ --term-line: #262626;
+ --term-text: #e8e8e8;
+
color-scheme: only dark;
- background: #0d0d0d;
- border: 1px solid #262626;
- color: #e8e8e8;
+ background: var(--term-bg);
+ border: 1px solid var(--term-line);
+ color: var(--term-text);
font-size: 0.78rem;
line-height: 1.5;
padding: 0.7rem 0.8rem;
diff --git a/site/waves.dom.test.js b/site/waves.dom.test.js
index d482f95..4e11041 100644
--- a/site/waves.dom.test.js
+++ b/site/waves.dom.test.js
@@ -103,7 +103,30 @@ function makeEl(tag, doc) {
width: 0,
height: 0,
box: { width: 1200, height: 400, left: 0, top: 0, right: 1200, bottom: 400 },
- classList: { added: [], add(c) { this.added.push(c); } },
+ classList: {
+ added: [],
+ add(c) { if (this.added.indexOf(c) < 0) this.added.push(c); },
+ remove(c) { this.added = this.added.filter((x) => x !== c); },
+ contains(c) { return this.added.indexOf(c) >= 0; },
+ },
+ textContent: '',
+ offsetWidth: 0,
+ closest(sel) {
+ // Enough of a match for the seek test: the class list against a
+ // comma-separated list of class selectors, walking up parents.
+ const want = sel.split(',').map((s) => s.trim().replace(/^\./, ''));
+ let node = el;
+ while (node) {
+ const have = String(node.className).split(/\s+/);
+ if (want.some((w) => have.indexOf(w) >= 0)) return node;
+ node = node.parent;
+ }
+ return null;
+ },
+ querySelector(sel) {
+ const want = sel.replace(/^\./, '');
+ return this.children.filter((c) => String(c.className).split(/\s+/).indexOf(want) >= 0)[0] || null;
+ },
getContext(kind) { return kind === '2d' ? (this.ctx = this.ctx || makeCtx()) : null; },
getBoundingClientRect() { return this.box; },
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; },
@@ -127,8 +150,14 @@ function makeWorld(opts) {
readyState: 'complete',
documentElement: null,
oceans: [],
+ gauges: [],
createElement(tag) { return makeEl(tag, doc); },
- querySelectorAll() { return doc.oceans; },
+ // The page holds two kinds of thing waves.js goes looking for, and
+ // they are not the same list — handing the oceans back for every
+ // selector would have the gauge lookup find canvases.
+ querySelectorAll(sel) {
+ return String(sel).indexOf('depth-gauge') >= 0 ? doc.gauges : doc.oceans;
+ },
addEventListener(k, fn) { (listeners.document[k] = listeners.document[k] || []).push(fn); },
removeEventListener(k, fn) {
listeners.document[k] = (listeners.document[k] || []).filter((f) => f !== fn);
@@ -136,6 +165,8 @@ function makeWorld(opts) {
fire(k, ev) { (listeners.document[k] || []).forEach((fn) => fn(ev || {})); },
};
doc.documentElement = makeEl('html', doc);
+ doc.documentElement.scrollHeight = opts.pageHeight || 0;
+ doc.documentElement.style.setProperty = function (k, v) { this[k] = v; };
doc.defaultView = null;
const sandbox = {
@@ -183,6 +214,7 @@ function makeWorld(opts) {
listeners.window[k] = (listeners.window[k] || []).filter((f) => f !== fn);
},
pageYOffset: 0,
+ innerHeight: opts.innerHeight || 0,
};
if (!opts.noObservers) {
sandbox.ResizeObserver = function (fn) {
@@ -217,6 +249,22 @@ function makeWorld(opts) {
doc.oceans.push(el);
return el;
},
+ // A depth gauge, shaped like the one build.py emits: a rail with a
+ // fill in it, and a reading the descent writes into.
+ gauge() {
+ const el = makeEl('div', doc);
+ const read = makeEl('span', doc);
+ read.className = 'gauge-read';
+ el.appendChild(read);
+ doc.gauges.push(el);
+ return el;
+ },
+ // Scroll to a fraction of the page and let the shared listener see it.
+ scrollTo(fraction) {
+ const range = (opts.pageHeight || 0) - (opts.innerHeight || 0);
+ sandbox.pageYOffset = Math.round(range * fraction);
+ world.fireWindow('scroll');
+ },
// Run one animation frame. The engine re-queues itself from inside
// the callback, so drain a snapshot rather than the live list.
frame(now) {
@@ -505,6 +553,160 @@ console.log('\ninteraction');
sea.destroy();
}
+// --- the descent --------------------------------------------------------
+
+// Scroll *position*, as opposed to the scroll *speed* the block above
+// covers. The two are separate signals into the same state and the easy
+// mistake is wiring one to the other's job, so they are tested apart.
+
+console.log('\ngoing deep');
+
+{
+ const world = makeWorld({ pageHeight: 5000, innerHeight: 1000 });
+ world.ocean({});
+ world.gauge();
+ const W = world.load();
+ const sea = W.mounted[0];
+
+ check('a page at the top is at the surface', W.scroll.depth === 0, String(W.scroll.depth));
+ world.scrollTo(0.5);
+ check('halfway down the page is halfway down the water',
+ Math.abs(W.scroll.depth - 0.5) < 0.001, String(W.scroll.depth));
+ check('and it is published to CSS for the page furniture',
+ parseFloat(world.doc.documentElement.style['--depth']) === 0.5,
+ String(world.doc.documentElement.style['--depth']));
+ check('and written into the gauge',
+ world.doc.gauges[0].children[0].textContent === '-500 m',
+ world.doc.gauges[0].children[0].textContent);
+ world.scrollTo(0);
+ check('the surface reads zero, not minus zero',
+ world.doc.gauges[0].children[0].textContent === '0 m',
+ world.doc.gauges[0].children[0].textContent);
+ world.scrollTo(0.5);
+
+ // The sea chases the published depth rather than snapping to it.
+ check('the water does not teleport to it', sea.st.depth < 0.5, String(sea.st.depth));
+ for (let i = 0; i < 200; i++) sea.advance(0.05);
+ check('but it does get there', Math.abs(sea.st.depth - 0.5) < 0.01, String(sea.st.depth));
+
+ // Every layer has to clear the top of the frame by the bottom of the
+ // page, or the deep is just the sea sitting slightly higher up.
+ const atBottom = { depth: 1, chop: 0, ripples: [], bulge: { strength: 0 } };
+ const cleared = W.LAYERS.every((l) => W.layerBase(l, atBottom) < 0);
+ check('at the bottom of the page the surface is overhead', cleared);
+ check('and nearer layers climbed further than far ones',
+ W.layerBase(W.LAYERS[3], atBottom) < W.layerBase(W.LAYERS[0], atBottom) + 0.31,
+ String(W.layerBase(W.LAYERS[0], atBottom) - W.layerBase(W.LAYERS[3], atBottom)));
+ check('a state with no depth in it is at the surface',
+ W.layerBase(W.LAYERS[0], { depth: 0 }) === W.layerBase(W.LAYERS[0], {}));
+
+ // The whale lags the surface. Without that it leaves with the light and
+ // the deep is an empty canvas.
+ const surface = W.layerBase(W.LAYERS[W.WHALE_LAYER], atBottom);
+ const whale = W.whaleTransform(0, 1200, 400, {
+ depth: 1, chop: 0, ripples: [], bulge: { strength: 0 },
+ whaleX: 0.5, whaleSpan: 1, facing: 1,
+ });
+ check('the whale sinks with you but stays in frame',
+ whale.y > surface * 400 && whale.y > 0 && whale.y < 400,
+ `${Math.round(whale.y)} vs surface ${Math.round(surface * 400)}`);
+
+ sea.destroy();
+}
+
+{
+ // A page no longer than its window cannot be descended, and the
+ // arithmetic for that is a division by zero.
+ const world = makeWorld({ pageHeight: 800, innerHeight: 800 });
+ world.ocean({});
+ const W = world.load();
+ world.scrollTo(1);
+ check('a page with nothing to scroll is never deep', W.scroll.depth === 0, String(W.scroll.depth));
+ W.mounted[0].destroy();
+}
+
+// --- seek ---------------------------------------------------------------
+
+console.log('\nseek');
+
+{
+ const world = makeWorld();
+ world.ocean({});
+ const W = world.load();
+ const sea = W.mounted[0];
+ const fire = (x, y) => world.fireWindow('pointerdown', { clientX: x, clientY: y });
+
+ fire(200, 100);
+ check('a click sends a ping', sea.st.pings.length === 1);
+ check('from where it was clicked',
+ sea.st.pings[0].x === 200 && sea.st.pings[0].y === 100);
+ check('and the ring starts at nothing and grows',
+ W.pingRadius(sea.st.pings[0], sea.t, sea.w) === 0);
+ sea.advance(0.5);
+ check('outward', W.pingRadius(sea.st.pings[0], sea.t, sea.w) > 0);
+
+ for (let i = 0; i < 20; i++) fire(200, 100);
+ check('a held button does not queue them up without limit',
+ sea.st.pings.length <= 7, String(sea.st.pings.length));
+
+ for (let i = 0; i < 100; i++) sea.advance(0.05);
+ check('and a spent ping is forgotten', sea.st.pings.length === 0);
+ sea.destroy();
+}
+
+{
+ // The return. A ping aimed at where the whale is has to light it up
+ // when the ring gets there — and not before, which is the half that
+ // makes it read as an echo rather than a highlight.
+ const world = makeWorld();
+ world.ocean({});
+ const W = world.load();
+ const sea = W.mounted[0];
+ world.frame(16);
+ const pos = sea.whalePos;
+ check('the whale reports where it was drawn', !!pos && pos.x > 0);
+
+ world.fireWindow('pointerdown', { clientX: pos.x + 400, clientY: pos.y });
+ sea.advance(0.05);
+ check('nothing comes back straight away', sea.st.echo === 0, String(sea.st.echo));
+ let peak = 0;
+ for (let i = 0; i < 40; i++) { sea.advance(0.05); peak = Math.max(peak, sea.st.echo); }
+ check('the ring reaches it and it answers', peak > 0.3, String(peak));
+ for (let i = 0; i < 60; i++) sea.advance(0.05);
+ check('and the answer fades', sea.st.echo < 0.02, String(sea.st.echo));
+ sea.destroy();
+}
+
+{
+ // The DOM half of the same gesture.
+ const world = makeWorld();
+ world.ocean({});
+ const W = world.load();
+ const btn = makeEl('a', world.doc);
+ btn.className = 'btn';
+ world.doc.fire('pointerdown', { target: btn });
+ check('clicking a control pings the control too', btn.classList.contains('is-seeking'));
+ world.doc.fire('animationend', { animationName: 'seek-out', target: btn });
+ check('and it is cleaned up when the pulse ends', !btn.classList.contains('is-seeking'));
+ W.mounted[0].destroy();
+}
+
+{
+ const world = makeWorld({ reducedMotion: true });
+ world.ocean({});
+ const W = world.load();
+ const sea = W.mounted[0];
+ const btn = makeEl('a', world.doc);
+ btn.className = 'btn';
+ world.doc.fire('pointerdown', { target: btn });
+ check('reduced motion gets no pulse at all', !btn.classList.contains('is-seeking'));
+ world.fireWindow('pointerdown', { clientX: 100, clientY: 100 });
+ check('and no ring frozen on the canvas either', sea.st.pings.length === 0);
+ check('though the water still answers the click',
+ sea.st.ripples.length === 1, String(sea.st.ripples.length));
+ sea.destroy();
+}
+
// --- sizing and theme ---------------------------------------------------
console.log('\nsizing and theme');
@@ -598,8 +800,13 @@ console.log('\ndegrading');
check('no ResizeObserver still leaves a working sea', sea && sea.frames >= 0);
world.frame(16);
check('and it draws', sea.canvas.ctx.ops.length > 0);
+ // Two of them, and they are not a double-wire: one re-measures the
+ // canvas, the other re-measures the scrollable range, because a window
+ // that changed shape puts the same scroll offset at a different depth.
+ // Counting is still how a genuine double-wire would be caught.
check('falling back to a window resize listener',
- (world.listeners.window.resize || []).length === 1);
+ (world.listeners.window.resize || []).length === 2,
+ String((world.listeners.window.resize || []).length));
sea.destroy();
}
diff --git a/site/waves.js b/site/waves.js
index f5f4b4e..3a625a8 100644
--- a/site/waves.js
+++ b/site/waves.js
@@ -1,4 +1,17 @@
-// Live water, a whale in it, and at night a sky of stars over it.
+// Live water, a whale in it, at night a sky of stars over it — and a page
+// that sinks through the lot of it as you read.
+//
+// The name of the tool is two verbs, and the page performs both. Scrolling
+// is *deep*: the surface climbs out of the frame, the stars go out, marine
+// snow starts streaming up past you, and the water darkens toward the
+// abyss. Clicking is *seek*: a sonar ring goes out from the pointer, and
+// when it reaches the whale the whale answers. Down in the dark that
+// return is the only way to see it, which is the whole idea — the deeper
+// you are, the more you have to ping for what is down there with you.
+//
+// Both are driven by one scalar each — `scroll.depth` for the descent,
+// the ping's own age for the seek — so nothing here is on a timer that
+// runs whether you are looking or not.
//
// Four wave layers, each a sum of three sines, drawn back to front onto a
// 2D canvas. The whale is filled between layer 1 and layer 2, so the two
@@ -23,6 +36,13 @@
// back out of the same height field, which is why it looks like it is
// floating instead of being animated on a timer.
//
+// Scroll is read twice, and the two readings do different jobs. Scroll
+// *speed* roughs the surface up (`scroll.energy`); scroll *position* is
+// how deep you are (`scroll.depth`). Position is the one that carries the
+// descent, and it is published to CSS as `--depth` so the page furniture
+// — the gauge in the margin — descends on the same number rather than on
+// a second scroll listener that could disagree with this one.
+//
// No dependencies, no build step, no WebGL. It runs at a few hundred
// sine calls per frame, which is nothing, and it stops entirely when it
// is off-screen, when the tab is hidden, or when the reader has asked for
@@ -158,6 +178,67 @@
band: { layers: BAND_LAYERS, sky: 0.05, span: 0.34, minSpan: 300, narrowBoost: 0.30 },
};
+ // ---------------------------------------------------------------- depth
+
+ // How far the surface climbs out of the frame over a full descent, in
+ // canvas heights. Every layer has to clear the top edge by the bottom of
+ // the page or the "deep" reads as "the sea moved up a bit".
+ var DIVE_RISE = 0.95;
+
+ // ... and the parallax while it climbs. Near layers rise faster than far
+ // ones, which is the same rule that makes the surface read as a surface
+ // when it is sitting still. `reach` is already a nearness gradient, so
+ // it does this job too rather than a second table of numbers.
+ var DIVE_PARALLAX = 0.5;
+
+ // The whale sinks with you, but not as fast — it lags the surface, so it
+ // is still in frame (dim, and well above you) once you are in the dark
+ // rather than having left with the light. Losing the animal entirely at
+ // the point the page gets interesting is not a descent, it is an empty
+ // canvas.
+ var WHALE_LAG = 0.55;
+
+ // The surface for one layer, at this depth. Everything that asks where
+ // the water is goes through here, including the whale, so the descent
+ // costs one term rather than a special case per caller.
+ // A state with no depth in it is at the surface. Defaulting rather than
+ // requiring it keeps `surfaceAt` callable with the state a caller would
+ // naturally write, which is how it was callable before there was a
+ // descent at all.
+ function layerBase(layer, st) {
+ var d = st.depth || 0;
+ return layer.base - d * DIVE_RISE * (1 - DIVE_PARALLAX + layer.reach * DIVE_PARALLAX);
+ }
+
+ // The same state at a different depth, for a caller that wants to know
+ // where the water would be if it had not sunk so far. Only the whale
+ // uses it, and it borrows the live ripples and bulge by reference so the
+ // surface it rides is the one everyone else can see.
+ function shallow(st, depth) {
+ // Copied wholesale rather than field by field: the profile fields
+ // (layers, span floors) live on this state too, and a list of names
+ // here would quietly drop whatever was added last.
+ var out = {};
+ for (var k in st) out[k] = st[k];
+ out.depth = depth;
+ return out;
+ }
+
+ // ---------------------------------------------------------------- seek
+
+ // A ping is a sonar pulse: one expanding ring, thinning and fading as it
+ // spends itself. It is drawn over the water rather than in it because it
+ // is not water — it is the instrument, and instruments read on top.
+ var PING_LIFE = 2.6; // seconds until it is spent
+ var PING_SPEED = 0.58; // canvas widths per second
+
+ // The return. When a ring's radius passes the whale, the whale answers
+ // for about as long as it takes to notice — bright, then gone.
+ var ECHO_BAND = 0.06; // how near the ring has to be, as a fraction of width
+ var ECHO_DECAY = 1.7; // per second
+
+ function pingRadius(p, t, w) { return (t - p.t0) * PING_SPEED * w; }
+
// ---------------------------------------------------------------- stars
// The sky band: stars live between the top of the frame and just above
@@ -231,6 +312,42 @@
return pts;
}
+ // ---------------------------------------------------------------- snow
+
+ // Marine snow: the drift of dead matter falling through the water
+ // column, and the one cue that says *descending* rather than merely
+ // *dark*. It streams up past you because you are going down — the
+ // movement is yours, not its, which is why the parallax below is keyed
+ // to depth and not to the clock. The slow constant sink on top of that
+ // is the snow's own, and it is what keeps the field alive when you stop
+ // scrolling.
+ var SNOW_START = 0.10; // depth at which any of it is visible
+ var SNOW_FULL = 0.34; // ... and at which it is at full strength
+ var SNOW_RISE = 2.4; // screens travelled per unit of depth, nearest fleck
+
+ // Dealt from the same seeded generator as the sky, on its own salts: a
+ // reproducible field, for the same two reasons the stars are one.
+ function snowField(w, h) {
+ var n = Math.max(40, Math.min(150, Math.round((w * h) / 12000)));
+ var out = [];
+ for (var i = 0; i < n; i++) {
+ var near = starRand(i, 21); // 0 far .. 1 near
+ out.push({
+ x: starRand(i, 22),
+ y: starRand(i, 23),
+ // Nearer flecks are bigger, brighter and move more. One parameter
+ // driving all three is what makes a flat field read as a volume.
+ r: 0.6 + near * near * 2.2,
+ a: 0.14 + near * 0.32,
+ near: 0.25 + near * 0.75,
+ // A little sideways set, so it is a drift rather than a lift.
+ drift: (starRand(i, 24) - 0.5) * 0.05,
+ sink: 0.010 + starRand(i, 25) * 0.026,
+ });
+ }
+ return out;
+ }
+
// Is a resolved colour worth drawing at all? The star tokens resolve to
// fully-transparent on the light theme, which is how CSS says "by day
// there are no stars" without this file knowing what a theme is.
@@ -294,7 +411,7 @@
// the water; past about double the resting amplitude it stops reading
// as swell and starts reading as static.
function surfaceAt(layer, x, t, w, h, st) {
- var y = layer.base * h + waveAt(layer, x, t, w) * h * (1 + st.chop * 0.7);
+ var y = layerBase(layer, st) * h + waveAt(layer, x, t, w) * h * (1 + st.chop * 0.7);
var e = bulgeAt(st.bulge, x, w, h);
for (var i = 0; i < st.ripples.length; i++) e += rippleAt(st.ripples[i], x, t, w);
return y + e * layer.reach;
@@ -334,6 +451,9 @@
// heavy would — late, and not very far.
function whaleTransform(t, w, h, st) {
var narrow = w < NARROW;
+ // It reads the water through a shallower copy of the descent, which is
+ // the whole of WHALE_LAG: the surface leaves, the whale stays a while.
+ if (st.depth) st = shallow(st, st.depth * WHALE_LAG);
// The profile decides the floor and the narrow-screen boost: the
// viewport whale is deliberately wider than the viewport (an enormous
// animal is one you cannot see the ends of), while the band whale is
@@ -387,6 +507,10 @@
return {
sea: sea,
deep: read('--sea-deep', '#00131c'),
+ // Where the light has stopped reaching. Distinct from --sea-deep,
+ // which is the floor of a layer's gradient at the surface: this one
+ // is the colour of being *in* it.
+ abyss: read('--sea-abyss', '#00131c'),
foam: read('--sea-foam', '#ffffff'),
whale: read('--whale', '#0b2f4a'),
whaleLit: read('--whale-lit', '#00c2e9'),
@@ -413,6 +537,20 @@
// The var() fallback does double duty — an undefined token resolves to
// the default here rather than making the declaration invalid and
// leaving `color` at whatever it inherited.
+ // The same colour at a chosen alpha. Everything cssResolver hands back
+ // has been through `getComputedStyle().color`, so it is `rgb(r, g, b)`
+ // or `rgba(r, g, b, a)` and nothing else — which is what makes this
+ // three lines instead of a colour parser. A string in any other shape is
+ // handed back untouched rather than turned into a canvas exception.
+ function withAlpha(colour, a) {
+ var m = /^rgba?\(([^)]+)\)$/.exec(String(colour).trim());
+ if (!m) return colour;
+ var p = m[1].split(',');
+ if (p.length < 3) return colour;
+ var base = p.length > 3 ? parseFloat(p[3]) : 1;
+ return 'rgba(' + p[0].trim() + ',' + p[1].trim() + ',' + p[2].trim() + ',' + (base * a) + ')';
+ }
+
function cssResolver(doc) {
var probe = doc.createElement('span');
probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden';
@@ -429,16 +567,57 @@
// ------------------------------------------------------------ instances
- // One scroll listener for the whole page. Every ocean on the page reads
- // the same energy, so scrolling roughs up all of them together — which
- // is what one body of water would do.
- var scroll = { last: 0, energy: 0, wired: false };
+ // One scroll listener for the whole page, reporting two different
+ // things. Every ocean reads the same `energy`, so scrolling roughs up
+ // all of them together — which is what one body of water would do — and
+ // the same `depth`, so they are all at the same depth, which is what one
+ // body of water would also do.
+ var scroll = { last: 0, energy: 0, depth: 0, wired: false };
+
+ // The deepest the gauge will admit to. A round number rather than a
+ // per-page one: a fixed scale means "-400 m" means the same thing on
+ // every page, and the alternative — metres derived from document height
+ // — would have a short page reaching the abyss in two flicks.
+ var FLOOR_M = 1000;
+
+ // Scroll position as a fraction of the scrollable range. A page shorter
+ // than the window has no range and is therefore never deep — clamping
+ // rather than dividing by zero is the whole of that case.
+ function depthOf(win, doc) {
+ var el = doc.documentElement || {};
+ var body = doc.body || {};
+ var height = Math.max(el.scrollHeight || 0, body.scrollHeight || 0);
+ var range = height - (win.innerHeight || el.clientHeight || 0);
+ if (!(range > 0)) return 0;
+ return Math.max(0, Math.min(1, (win.pageYOffset || 0) / range));
+ }
- function wireScroll(win) {
+ // Publish the descent to the page: as `--depth` on the root, so CSS can
+ // read it, and as text in any depth gauge. One number, one owner — a
+ // second scroll listener somewhere in a stylesheet's worth of JS is how
+ // the margin ends up disagreeing with the water.
+ function publishDepth(doc, d) {
+ var el = doc.documentElement;
+ if (el && el.style && el.style.setProperty) {
+ el.style.setProperty('--depth', d.toFixed(4));
+ }
+ // At the surface it is zero, not minus zero. The sign means "below",
+ // and there is no below at the top of the page.
+ var m = Math.round(d * FLOOR_M);
+ var label = (m > 0 ? '-' + m : '0') + ' m';
+ var gauges = doc.querySelectorAll ? doc.querySelectorAll('[data-depth-gauge]') : [];
+ for (var i = 0; i < gauges.length; i++) {
+ var read = gauges[i].querySelector && gauges[i].querySelector('.gauge-read');
+ if (read) read.textContent = label;
+ }
+ }
+
+ function wireScroll(win, doc) {
if (scroll.wired || !win.addEventListener) return;
scroll.wired = true;
scroll.last = win.pageYOffset || 0;
- win.addEventListener('scroll', function () {
+ doc = doc || win.document;
+ var sync = function () {
var now = win.pageYOffset || 0;
var dv = Math.abs(now - scroll.last);
scroll.last = now;
@@ -446,7 +625,15 @@
// to static. At the old ceiling a fast scroll doubled the swell
// amplitude under the text you were trying to read.
scroll.energy = Math.min(0.45, scroll.energy + dv / 600);
- }, { passive: true });
+ scroll.depth = depthOf(win, doc);
+ if (doc) publishDepth(doc, scroll.depth);
+ };
+ win.addEventListener('scroll', sync, { passive: true });
+ // A resize changes the scrollable range, so the same offset is now a
+ // different depth. Reloading onto an anchor lands mid-page too, which
+ // is why this runs once here rather than only on the first scroll.
+ win.addEventListener('resize', sync, { passive: true });
+ sync();
}
function Ocean(host, opts) {
@@ -488,7 +675,10 @@
this.st = {
chop: 0,
+ depth: 0,
+ echo: 0,
ripples: [],
+ pings: [],
spout: [],
bulge: { x: 0, strength: 0, target: 0 },
whaleX: opts.whaleX != null ? opts.whaleX : 0.5,
@@ -556,17 +746,19 @@
this.step = Math.max(4, w / 220);
this.n = Math.ceil(w / this.step) + 2;
this.pts = new Float64Array(this.n);
- // The star and glitter counts follow the frame's area, so a resize
- // deals both fields again from the same seeds.
+ // The star, glitter and snow counts all follow the frame's area, so a
+ // resize deals every field again from the same seeds.
this.stars = null;
this.glints = null;
+ this.snow = null;
return true;
};
Ocean.prototype.wire = function () {
var self = this;
var win = this.win;
- wireScroll(win);
+ wireScroll(win, this.doc);
+ this.st.depth = scroll.depth;
// The canvas is pointer-events: none so it never shadows a link, so
// the pointer has to be tracked on the window and mapped in. The
@@ -585,6 +777,7 @@
this.onDown = function (e) {
var r = self.canvas.getBoundingClientRect();
self.splash(e.clientX - r.left, 1);
+ self.ping(e.clientX - r.left, e.clientY - r.top);
};
if (win.addEventListener) {
@@ -656,6 +849,24 @@
}
};
+ // The seek half of the name: a sonar pulse from where you clicked.
+ // Separate from `splash` because they are two different claims about the
+ // same click — the splash is what the water does, the ping is what you
+ // did. They also live for different lengths of time and are drawn on
+ // opposite sides of the whale.
+ Ocean.prototype.ping = function (x, y) {
+ if (!this.visible) return;
+ // A pulse that cannot travel is not a quieter pulse, it is a ring
+ // painted on the page and left there — the still frame reduced motion
+ // gets would freeze it at whatever radius it had reached. The water's
+ // own answer to a click is a bump in a height field and survives being
+ // frozen; a circle does not.
+ if (this.reduced()) return;
+ if (this.st.pings.length > 6) this.st.pings.shift();
+ this.st.pings.push({ x: x, y: y, t0: this.t });
+ if (!this.running()) this.draw(this.t);
+ };
+
Ocean.prototype.running = function () { return !!this.raf; };
Ocean.prototype.start = function () {
@@ -691,6 +902,10 @@
// so the chop builds while you are flicking and settles when you stop.
st.chop += (Math.min(1, scroll.energy) - st.chop) * Math.min(1, dt * 3);
scroll.energy *= Math.exp(-dt * 2.2);
+ // The descent is chased rather than tracked. A scrollbar drag would
+ // otherwise teleport the sea, and water does not teleport; the lag is
+ // small enough to read as mass and large enough to smooth a jump.
+ st.depth += (scroll.depth - st.depth) * Math.min(1, dt * 3.4);
var live = [];
for (var i = 0; i < st.ripples.length; i++) {
@@ -698,6 +913,25 @@
}
st.ripples = live;
+ // Pings, and what comes back off them. The whale's position is the one
+ // the last frame drew, which is a frame stale and invisibly so — the
+ // alternative is computing the transform twice per frame for a flash
+ // that lasts a fifth of a second.
+ var pings = [];
+ st.echo *= Math.exp(-dt * ECHO_DECAY);
+ for (var k = 0; k < st.pings.length; k++) {
+ var p = st.pings[k];
+ if (this.t - p.t0 > PING_LIFE) continue;
+ pings.push(p);
+ if (!this.whalePos) continue;
+ var dx = this.whalePos.x - p.x;
+ var dy = this.whalePos.y - p.y;
+ var band = this.w * ECHO_BAND;
+ var miss = Math.abs(pingRadius(p, this.t, this.w) - Math.sqrt(dx * dx + dy * dy));
+ if (miss < band) st.echo = Math.max(st.echo, 1 - miss / band);
+ }
+ st.pings = pings;
+
if (this.whale && this.t > st.nextSpout) {
this.blow();
st.nextSpout = this.t + 11 + (this.t * 7919 % 9);
@@ -775,10 +1009,19 @@
ctx.lineTo(0, h + 2);
ctx.closePath();
- var g = ctx.createLinearGradient(0, layer.base * h - h * 0.1, 0, h);
+ // The gradient has to start where the surface actually is, not where
+ // it rests. Anchored to the resting base instead, a descent fills the
+ // whole frame with the *top* of the ramp — canvas clamps to the first
+ // stop above it — and the deep comes out brighter than the surface,
+ // which is the exact opposite of the thing being drawn.
+ var g = ctx.createLinearGradient(0, layerBase(layer, this.st) * h - h * 0.1, 0, h);
g.addColorStop(0, pal.sea[layer.tint]);
g.addColorStop(1, pal.deep);
- ctx.globalAlpha = layer.alpha;
+ // A layer that has climbed out of the frame is behind you. It still
+ // has to paint — it is what the water between you and it looks like —
+ // but four full-strength layers stacked over the whole frame is a
+ // wall, not a depth.
+ ctx.globalAlpha = layer.alpha * (1 - this.st.depth * 0.45);
ctx.fillStyle = g;
ctx.fill();
@@ -787,7 +1030,7 @@
// they are water.
if (layer.foam > 0) {
this.surfacePath();
- ctx.globalAlpha = layer.foam;
+ ctx.globalAlpha = layer.foam * (1 - this.st.depth * 0.45);
ctx.strokeStyle = pal.foam;
ctx.lineWidth = 1;
ctx.stroke();
@@ -801,6 +1044,12 @@
Ocean.prototype.drawStars = function (t) {
var pal = this.palette;
if (!visibleColour(pal.star)) return;
+ // Under water there is no sky. They go out over the first eighth of
+ // the descent, which is about one screen on a docs page — long enough
+ // to watch happen, short enough that you are not reading a paragraph
+ // next to stars you have supposedly left above you.
+ var above = 1 - Math.min(1, this.st.depth / 0.12);
+ if (above <= 0) return;
if (!this.stars) {
// The band's sky is a sliver; the viewport's is half the frame. The
// clamps come down with it or the strip snows.
@@ -819,7 +1068,7 @@
// Atmospheric extinction: the sky pales toward the horizon, so the
// stars go with it instead of sitting on top of it.
var fade = 1 - (s.y / this.sky) * 0.55;
- var alpha = s.a * twinkle * fade;
+ var alpha = s.a * twinkle * fade * above;
ctx.globalAlpha = alpha;
ctx.fillStyle = s.warm ? pal.starWarm : pal.star;
ctx.beginPath();
@@ -871,16 +1120,30 @@
var ctx = this.ctx;
var pal = this.palette;
var tr = whaleTransform(t, this.w, this.h, this.st);
+ // Kept for the echo test in `advance`, which needs to know where the
+ // thing it is pinging actually ended up.
+ this.whalePos = { x: tr.x, y: tr.y };
+
+ // Distance dims it: down in the dark it is a suggestion of a shape,
+ // not an illustration. `echo` is the answer to a ping, and it is
+ // deliberately strongest exactly where the dimming is — the deeper you
+ // are, the more of what you see is something you asked for.
+ var echo = Math.min(1, this.st.echo);
+ var lost = Math.min(1, this.st.depth * 1.15);
+ var seen = (1 - lost * 0.72) + echo * (0.25 + lost * 0.7);
// A soft light behind it, so a dark silhouette on a dark page still
// has an edge to sit against.
var g = ctx.createRadialGradient(tr.x, tr.y, 0, tr.x, tr.y, tr.span * 0.62);
g.addColorStop(0, pal.glow);
g.addColorStop(1, pal.glowFade);
+ ctx.globalAlpha = Math.max(0, Math.min(1, seen));
ctx.fillStyle = g;
ctx.fillRect(tr.x - tr.span * 0.7, tr.y - tr.span * 0.7, tr.span * 1.4, tr.span * 1.4);
+ ctx.globalAlpha = 1;
ctx.save();
+ ctx.globalAlpha = Math.max(0.05, Math.min(1, seen));
ctx.translate(tr.x, tr.y);
ctx.rotate(tr.rot);
ctx.scale(tr.sx, tr.sy);
@@ -889,10 +1152,26 @@
ctx.fillStyle = pal.whale;
ctx.fill(this.body);
- // The far flipper, a shade darker than the body.
- ctx.globalAlpha = 0.55;
+ // The far flipper, a shade darker than the body. Canvas alpha is
+ // absolute rather than multiplied into what is already set, so every
+ // part that wants to be dimmer than the body has to say `seen` again
+ // — otherwise a fading whale keeps a bright flipper and a bright eye,
+ // which is a whale wearing jewellery in the dark.
+ ctx.globalAlpha = 0.55 * seen;
ctx.fill(this.flipper);
- ctx.globalAlpha = 1;
+
+ // The sonar return: the outline lights up where the ring passed. It is
+ // a stroke rather than a brighter fill because that is what coming
+ // back off an edge looks like, and because the shape is the
+ // information — you are being told *what* is down there, not how
+ // brightly it glows. The line width is divided back through the
+ // whale's own scale so it stays a hairline on screen at any size.
+ if (echo > 0.02) {
+ ctx.globalAlpha = Math.min(1, echo * 0.9);
+ ctx.strokeStyle = pal.whaleLit;
+ ctx.lineWidth = 3 / Math.max(0.001, Math.abs(tr.sx));
+ ctx.stroke(this.body);
+ }
// The mouth line and the eye have to be lighter than the body or a
// silhouette swallows them — and they are most of what makes the
@@ -905,7 +1184,7 @@
ctx.stroke(this.mouth);
}
ctx.fillStyle = pal.whaleLit;
- ctx.globalAlpha = 0.75;
+ ctx.globalAlpha = Math.min(1, 0.75 * seen + echo * 0.6);
ctx.beginPath();
ctx.arc(WHALE.eye.x, WHALE.eye.y, WHALE.eye.r, 0, Math.PI * 2);
ctx.fill();
@@ -929,6 +1208,91 @@
ctx.globalAlpha = 1;
};
+ // The water you are inside, as opposed to the water you are looking at.
+ // Once the surface has climbed out of the frame the layers stop covering
+ // the bottom of it, and without this the deep is just the page
+ // background — which is to say, nothing happened.
+ //
+ // It is a gradient rather than a flat wash because the light in real
+ // water comes from above: even at the bottom, up is brighter than down.
+ Ocean.prototype.drawAbyss = function () {
+ var d = this.st.depth;
+ if (d <= 0.01) return;
+ var ctx = this.ctx;
+ var g = ctx.createLinearGradient(0, 0, 0, this.h);
+ // Not transparent at the top: at full depth the dark is *everywhere*,
+ // and a veil that fades out upward reads as a surface just overhead —
+ // which is the one thing you are supposed to have left behind. The
+ // gradient that remains is the light still coming from above.
+ g.addColorStop(0, withAlpha(this.palette.abyss, 0.5));
+ g.addColorStop(1, this.palette.abyss);
+ ctx.globalAlpha = Math.min(1, d * 1.25);
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, this.w, this.h);
+ ctx.globalAlpha = 1;
+ };
+
+ // Marine snow, in front of the water because it is between you and it.
+ Ocean.prototype.drawSnow = function (t) {
+ var d = this.st.depth;
+ if (d <= SNOW_START) return;
+ var pal = this.palette;
+ if (!visibleColour(pal.foam)) return;
+ if (!this.snow) this.snow = snowField(this.w, this.h);
+ var strength = Math.min(1, (d - SNOW_START) / (SNOW_FULL - SNOW_START));
+ var ctx = this.ctx;
+ ctx.fillStyle = pal.foam;
+ for (var i = 0; i < this.snow.length; i++) {
+ var s = this.snow[i];
+ // Depth moves it up (that is you, going down); time moves it down
+ // (that is the snow, falling). Wrapping on 1 keeps the field
+ // seamless without dealing new flecks at the edges.
+ var y = s.y - d * SNOW_RISE * s.near + t * s.sink;
+ y = y - Math.floor(y);
+ var x = s.x + s.drift * (d * SNOW_RISE * s.near) + Math.sin(t * 0.2 + s.x * 9) * 0.004;
+ x = x - Math.floor(x);
+ ctx.globalAlpha = s.a * strength;
+ ctx.beginPath();
+ ctx.arc(x * this.w, y * this.h, s.r, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ };
+
+ // The pulse itself. Two arcs: the ring, and a fainter one just behind it
+ // for the trail. Both thin — a sonar sweep is a line, and a thick soft
+ // ring reads as a button's ripple, which is the wrong idea entirely.
+ Ocean.prototype.drawPings = function (t) {
+ var st = this.st;
+ if (!st.pings.length) return;
+ var ctx = this.ctx;
+ var pal = this.palette;
+ for (var i = 0; i < st.pings.length; i++) {
+ var p = st.pings[i];
+ var age = t - p.t0;
+ if (age < 0) continue;
+ var r = pingRadius(p, t, this.w);
+ if (r <= 0) continue;
+ // Squared, so it dies out of sight rather than switching off — the
+ // same fade the water ripple uses, for the same reason.
+ var fade = 1 - age / PING_LIFE;
+ fade = fade * fade;
+ ctx.strokeStyle = pal.foam;
+ ctx.lineWidth = 1;
+ ctx.globalAlpha = 0.5 * fade;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
+ ctx.stroke();
+ if (r > this.w * 0.06) {
+ ctx.globalAlpha = 0.16 * fade;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, r - this.w * 0.045, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+ }
+ ctx.globalAlpha = 1;
+ };
+
Ocean.prototype.draw = function (t) {
if (!this.w || !this.h) return;
var ctx = this.ctx;
@@ -941,7 +1305,16 @@
}
this.drawLayer(this.layers[i], t);
}
+ // Order is the whole argument for where these four go. Glitter is
+ // light lying on the water, so it belongs with the water and ahead of
+ // the dark that has to be able to swallow it; the dark is between you
+ // and the water; the snow is between you and the dark; and the ping is
+ // not in the water at all — it is the instrument reading, so it sits
+ // on top of everything.
this.drawGlitter(t);
+ this.drawAbyss();
+ this.drawSnow(t);
+ this.drawPings(t);
this.frames++;
};
@@ -963,6 +1336,42 @@
if (this.canvas.remove) this.canvas.remove();
};
+ // -------------------------------------------------------------- controls
+
+ // The same gesture, on the things that are genuinely clickable. A click
+ // on the page pings the water; a click on a control pings from the
+ // control. Without this second half the page has two click languages —
+ // one for the scenery and one for the buttons — and the scenery's is the
+ // better one.
+ //
+ // The ring itself is CSS, on a pseudo-element of the control, so it
+ // starts at the control's own edge rather than somewhere behind it. All
+ // this does is start it: add a class, take it off when the animation
+ // ends. A second click mid-animation has to see the class removed and
+ // re-added or nothing restarts, which is what the reflow read is for.
+ var SEEKABLE = '.btn, .sitenav a, .themetoggle, .pager a, .pg-primary';
+
+ function wireSeek(doc) {
+ if (doc.dsSeekWired || !doc.addEventListener) return;
+ doc.dsSeekWired = true;
+ var win = doc.defaultView || global;
+ doc.addEventListener('pointerdown', function (e) {
+ if (win.matchMedia && win.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
+ var el = e.target && e.target.closest && e.target.closest(SEEKABLE);
+ if (!el || !el.classList) return;
+ el.classList.remove('is-seeking');
+ // Reading a layout property forces the class removal to take effect
+ // before it goes back on, which is what restarts the animation.
+ void el.offsetWidth;
+ el.classList.add('is-seeking');
+ }, { passive: true, capture: true });
+ doc.addEventListener('animationend', function (e) {
+ if (e.animationName === 'seek-out' && e.target.classList) {
+ e.target.classList.remove('is-seeking');
+ }
+ }, true);
+ }
+
// ----------------------------------------------------------------- mount
function mount(el, opts) {
@@ -982,6 +1391,7 @@
function auto(doc) {
var out = [];
+ wireSeek(doc);
var hosts = doc.querySelectorAll('[data-ocean]');
for (var i = 0; i < hosts.length; i++) {
var el = hosts[i];
@@ -1018,14 +1428,29 @@
TILT_GAIN: TILT_GAIN,
TILT_MAX: TILT_MAX,
SKY: SKY,
+ DIVE_RISE: DIVE_RISE,
+ WHALE_LAG: WHALE_LAG,
+ SNOW_START: SNOW_START,
+ SNOW_FULL: SNOW_FULL,
+ PING_LIFE: PING_LIFE,
+ PING_SPEED: PING_SPEED,
+ FLOOR_M: FLOOR_M,
+ scroll: scroll,
starRand: starRand,
starField: starField,
glitterField: glitterField,
+ snowField: snowField,
visibleColour: visibleColour,
waveAt: waveAt,
rippleAt: rippleAt,
bulgeAt: bulgeAt,
surfaceAt: surfaceAt,
+ layerBase: layerBase,
+ pingRadius: pingRadius,
+ depthOf: depthOf,
+ publishDepth: publishDepth,
+ wireSeek: wireSeek,
+ SEEKABLE: SEEKABLE,
whaleTransform: whaleTransform,
whaleToScreen: whaleToScreen,
readPalette: readPalette,