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 @@
+
@@ -54,7 +55,15 @@ >thevibeworks/deepseek-cli - +
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' + '\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 @@
+
@@ -54,7 +55,15 @@ >thevibeworks/deepseek-cli - +
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 @@
+
@@ -54,7 +55,15 @@ >thevibeworks/deepseek-cli - +
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 @@
+
@@ -45,7 +46,15 @@ >thevibeworks/deepseek-cli - +
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 @@
+
@@ -61,7 +62,15 @@ >thevibeworks/deepseek-cli - +
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 @@
+
@@ -54,7 +55,15 @@ >thevibeworks/deepseek-cli - +
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 @@
+
@@ -45,7 +46,15 @@ >thevibeworks/deepseek-cli - +
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 @@
+
@@ -54,7 +55,15 @@ >thevibeworks/deepseek-cli - +
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,