/* =========================================================================
   Tokens
   ========================================================================= */

:root {
  --sans: "Helvetica Neue", Helvetica, Arial, "Liberation Sans", sans-serif;
  --serif: Baskerville, "Libre Baskerville", Georgia, "Times New Roman", serif;

  --measure: 38rem;
  --gutter: 1.5rem;

  --step: 0.875rem;    /* body size  */
  --leading: 1.65;     /* body line-height */
  --family: var(--sans);
  --x-height: none;    /* see the serif block below */
  --serif-x-height: 0.52;   /* what the serif block sets; app.js reads it to measure labels */
  --title: 1.4286em;   /* 20px: section titles */
  --head: 1.2857em;    /* 18px: subheadings inside a section */
}

/* Baskerville has a much smaller x-height than Helvetica. Rather than bump
   the size and leading by hand — which reflows the page and makes the switch
   lurch — normalise the x-height and leave the declared size and line-height
   alone. Line boxes stay put; only the letterforms change. */
:root[data-font="serif"] {
  --family: var(--serif);
  --x-height: var(--serif-x-height);
}

/* Where font-size-adjust is unsupported, fall back to the manual bump. */
@supports not (font-size-adjust: 0.5) {
  :root[data-font="serif"] { --step: 0.9775rem; --leading: 1.58; }
}

:root[data-theme="light"] {
  --bg:        #ffffff;
  --text:      #000000;
  --muted:     #000000;
  --faint:     #000000;
  --rule:      #000000;
  --raised:    #000000;
  --link-line: #000000;
  --focus:     #000000;
}

:root[data-theme="dark"] {
  --bg:        #000000;
  --text:      #ffffff;
  --muted:     #ffffff;
  --faint:     #ffffff;
  --rule:      #ffffff;
  --raised:    #ffffff;
  --link-line: #ffffff;
  --focus:     #ffffff;
}

:root[data-theme="fun"] {
  --bg:        #ffffff;
  --text:      #000000;
  --muted:     #000000;
  --faint:     #000000;
  --rule:      #000000;
  --raised:    #000000;
  --link-line: #000000;
  --focus:     #000000;
}

/* =========================================================================
   Base
   ========================================================================= */

*, *::before, *::after { box-sizing: border-box; }

html {
  background: var(--bg);
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  font-size-adjust: var(--x-height);
  background: var(--bg);
  color: var(--text);
  font-family: var(--family);
  font-size: var(--step);
  line-height: var(--leading);
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

/* Only animate colour once the page has settled, so switching themes is
   smooth but the initial paint is instant. */
body.ready,
body.ready * {
  transition: background-color .22s ease, color .22s ease,
              border-color .22s ease, opacity .22s ease;
}

/* Holds the SVG filter the Fun theme points at. */
.filters {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.page {
  max-width: var(--measure);
  margin: 0 auto;
  padding: clamp(3rem, 9vw, 6rem) var(--gutter) 5rem;
}

/* =========================================================================
   Masthead
   ========================================================================= */

.masthead {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1.5rem 2rem;
  padding-bottom: 2rem;
  margin-bottom: 3rem;
  border-bottom: 1px solid var(--rule);
}

.identity { min-width: 0; }

.name {
  margin: 0;
  font-size: 1.7143em;   /* 24px */
  font-weight: 600;
  letter-spacing: -0.015em;
  line-height: 1.2;
}

.tagline {
  margin: .45rem 0 0;
  color: var(--muted);
  font-size: 1em;
  line-height: 1.5;
  text-wrap: pretty;
}

/* --- the two switches ---------------------------------------------------- */

.controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;   /* theme left, typeface right */
  gap: .5rem;
  flex: 1 1 17rem;
}

.switch {
  display: inline-flex;
  padding: 2px;
  border: 1px solid var(--rule);
  background: transparent;   /* in Fun mode the backdrop shows through */
}

.switch button {
  appearance: none;
  border: 0;
  margin: 0;
  padding: .3rem .7rem;
  background: transparent;
  color: var(--muted);
  font-family: var(--family);   /* the buttons change face with the page */
  font-size-adjust: var(--x-height);   /* the UA button font shorthand resets this */
  font-size: .75rem;
  text-align: center;           /* labels sit centred in the width app.js fixes */
  font-weight: 500;
  letter-spacing: .01em;
  line-height: 1.4;
  cursor: pointer;
  white-space: nowrap;
}

.switch button:hover { color: var(--text); }

.switch button[aria-pressed="true"] {
  background: var(--raised);
  color: var(--bg);
}

/* The Fun sub-switch: where the colour is. It sits beside the theme switch
   and slides out from the Fun button when Fun is pressed. A pill, to mark
   it as the child of the switch next to it; the pressed side wears the
   live hue, so it turns as the pointer travels. */
.theme-group {
  display: flex;
  align-items: center;
  gap: .5rem;
}

.fun-controls {
  display: flex;
  align-items: center;
  opacity: 0;
  translate: -.75rem 0;
  visibility: hidden;
  transition: opacity .35s ease, translate .35s cubic-bezier(.2, .8, .3, 1), visibility 0s .35s;
}

:root[data-theme="fun"] .fun-controls {
  opacity: 1;
  translate: 0 0;
  visibility: visible;
  transition: opacity .35s ease .1s, translate .35s cubic-bezier(.2, .8, .3, 1) .1s, visibility 0s;
}

/* Project pages have no Fun mode: the switch there is Light and Dark. */
:root[data-page="project"] [data-set-theme="fun"],
:root[data-page="project"] .fun-controls { display: none; }

/* A page fixed to one theme (a project's `theme:`) crosses the other out. */
.switch button[aria-disabled="true"],
.switch button[aria-disabled="true"]:hover {
  color: var(--muted);
  text-decoration: line-through;
  text-decoration-thickness: 1px;
  cursor: not-allowed;
}

.switch.pill,
.switch.pill button { border-radius: 999px; }

.switch.pill { position: relative; border-color: transparent; }
.switch.pill button { position: relative; z-index: 2; }

/* The pill has no visible border of its own (the width is kept so nothing
   shifts). Its only outline is the scalloped line drawn in the svg by
   assets/app.js, once, inset so its outer peaks stay within the pill's
   edge; it sits above the thumb and under the labels. The path is given
   a pathLength of 1000, so the dash below shows about three quarters of
   it and slides round the pill every nine seconds. */
.pill-line {
  position: absolute;
  inset: -8px;
  width: calc(100% + 16px);
  height: calc(100% + 16px);
  overflow: visible;
  pointer-events: none;
  z-index: 1;
  fill: none;
  stroke: var(--text);
  stroke-linecap: round;
  stroke-linejoin: round;
}

.pill-line path {
  stroke-width: 1.3;
  stroke-dasharray: 720 280;
  animation: pill-travel 9s linear infinite;
}

@keyframes pill-travel {
  to { stroke-dashoffset: -1000; }
}

@media (prefers-reduced-motion: reduce) {
  .pill-line path { animation: none; }
}

/* The pressed side is marked by the thumb underneath rather than the
   button itself, so it can travel. The thumb spans the pill's full height
   and runs out to the pill's edge on the outer side; assets/app.js clips
   it with the same scalloped path the line is drawn with, so the colour
   fills right out to the squiggle. */
.switch.pill button[aria-pressed="true"] {
  background: transparent;
  color: var(--text);
}

.thumb {
  position: absolute;
  top: -1px;
  bottom: -1px;
  left: var(--tx, 2px);
  width: var(--tw, 0);
  border-radius: 999px;
  background: linear-gradient(100deg, var(--c1), var(--c3), var(--c5));
  transform-origin: 50% 50%;
}

.thumb.hop { animation: thumb-hop .7s linear both; }

/* First it draws in to a circle where it is, then the circle crosses to
   the other side, stretching as it goes, and only then does it open out
   into the new side with a little overshoot. */
@keyframes thumb-hop {
  0% {
    left: var(--fx); width: var(--fw); scale: 1;
    animation-timing-function: cubic-bezier(.4, 0, .6, 1);
  }
  28% {
    left: calc(var(--fx) + (var(--fw) - var(--th)) / 2); width: var(--th); scale: 1;
    animation-timing-function: cubic-bezier(.5, 0, .5, 1);
  }
  46% {
    left: calc((var(--fx) + var(--fw) / 2 + var(--tx) + var(--tw) / 2) / 2 - var(--th) / 2);
    width: var(--th); scale: 1.3 .82;
    animation-timing-function: cubic-bezier(.5, 0, .5, 1);
  }
  64% {
    left: calc(var(--tx) + (var(--tw) - var(--th)) / 2); width: var(--th); scale: .92 1.08;
    animation-timing-function: cubic-bezier(.2, .9, .3, 1.3);
  }
  100% { left: var(--tx); width: var(--tw); scale: 1; }
}

/* =========================================================================
   Sections
   ========================================================================= */

.section + .section { margin-top: 3.25rem; }

/* Section titles sit about two points above the text under them, which is
   what separates them from the bold subheadings inside a section. */
.section > h2 {
  margin: 0 0 1.25rem;
  font-size: var(--title);
  font-weight: 600;
  letter-spacing: -0.005em;
  color: var(--text);
}

.section > h2:only-child { margin-bottom: 0; }

/* Subheadings inside a section. Same size as the section title above them,
   set in the regular weight, so the level reads without another size step. */
h3 {
  margin: 2rem 0 .5rem;
  font-size: var(--head);
  font-weight: 400;
  letter-spacing: -0.005em;
}

/* Locations in the CV. Two points under the body size, so they read as a
   label over the entries rather than as another heading. */
h4 {
  margin: 1.5rem 0 .4rem;
  font-size: .857em;
  font-weight: 600;
  color: var(--muted);
}

p { margin: 0 0 1.05em; text-wrap: pretty; }
p:last-child { margin-bottom: 0; }

a {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--link-line);
  text-decoration-thickness: 1px;
  text-underline-offset: .18em;
}

a:hover { text-decoration-color: currentColor; }

:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
  border-radius: 0;
}

strong { font-weight: 600; }

code {
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: .875em;
  border: 1px solid var(--rule);
  padding: .05em .3em;
}

blockquote {
  margin: 1.5em 0;
  padding-left: 1.1em;
  border-left: 2px solid var(--rule);
  color: var(--muted);
}

hr {
  height: 0;
  margin: 2.5rem 0;
  border: 0;
  border-top: 1px solid var(--rule);
}

/* =========================================================================
   Lists
   ========================================================================= */

ul, ol {
  margin: 0 0 1.05em;
  padding-left: 1.15em;
}

ul:last-child, ol:last-child { margin-bottom: 0; }

li { margin-bottom: .55em; text-wrap: pretty; }
li:last-child { margin-bottom: 0; }

li::marker { color: var(--rule); }

/* A `Main text :: right-hand note` row — used for CV entries and dated work. */
li.row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: .4rem 1.25rem;
  /* No wrapping: a long entry wraps inside its own column and the note
     stays on the first line, rather than being pushed onto a line of its
     own with empty space above it. */
  flex-wrap: nowrap;
}

li.row .item-main { min-width: 0; }

li.row .item-meta {
  color: var(--muted);
  font-size: .875em;
  white-space: nowrap;
  margin-left: auto;
}

/* An index list — every row has a right-hand note. Sits flush with the
   body text and drops the bullets, so it reads as a table of contents
   rather than a bulleted list. */
ul.rows, ol.rows {
  padding-left: 0;
  list-style: none;
}

ul.rows li, ol.rows li { margin-bottom: .7em; }

/* =========================================================================
   Figures — the images on a project page
   ========================================================================= */

/* Images sit in the text column, at its full width. Each links to its own
   file, so a board too dense to read here can be opened full size. */
.figure { margin: 2rem 0; }

.figure a,
.figure .clip { display: block; margin: 0 auto; }

.figure img,
.figure video {
  display: block;
  width: 100%;
  height: auto;
}

.figure video { background: #000; }

/* A drawing in ink on a clear ground (build.py's is_ink): black on the
   light page, turned white on the dark one. The hue turn puts any colour
   back after the invert (grey is unchanged), so a pale blue wash comes out
   a deep blue rather than orange. */
[data-theme="dark"] .figure img.ink { filter: invert(1) hue-rotate(180deg); }

/* An image with a version for the dark page (build.py's dark_twin): only
   the one for the theme shows. !important so that it holds against any
   rule that shows an image, like the riso print's still. */
[data-theme="dark"] .for-light,
:root:not([data-theme="dark"]) .for-dark { display: none !important; }

/* The date under a project's title, where the old site had it, and the
   credit under that: the school, the studio, and anyone it was made with. */
.project > .date,
.project > .credit {
  color: var(--muted);
  font-size: .875em;
}

.project > .date { margin: -.9rem 0 0; }
.project > .credit { margin: .15rem 0 0; }

/* Two or more in a row. Each takes a share of the width equal to its
   aspect ratio (set per image by build.py), so they meet at one height. */
.figure.row {
  display: flex;
  gap: .75rem;
}

.figure.row > * { flex: var(--ratio, 1) 1 0; min-width: 0; }

/* A picture with a second under it: the thermochromic paintings, heated.
   Hovering fades the heated one in, slowly, as the paint would; a tap or
   Enter holds it (app.js sets aria-pressed). */
.reveal .pair {
  display: grid;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.reveal .pair > img { grid-area: 1 / 1; }

.reveal .under {
  opacity: 0;
  transition: opacity 1.4s ease;
}

.reveal .pair[aria-pressed="true"] .under { opacity: 1; }

@media (hover: hover) {
  .reveal .pair:hover .under { opacity: 1; }
}

.reveal .pair:focus-visible { outline: 1px solid var(--focus); outline-offset: 3px; }

/* A chart (chart.js): horizontal bars in the page's ink, hollow for the
   reference and solid for the thing being tested, so no colour is needed.
   Each bar is a button: hover, focus or a tap says what it is below. */
.chart-title {
  font-weight: 700;
  margin-bottom: .6rem;
}

.chart-controls {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem;
  margin-bottom: .9rem;
}

.chart-legend {
  display: flex;
  flex-wrap: wrap;
  gap: .2rem 1.1rem;
  margin: 0 0 .6rem;
  padding: 0;
  list-style: none;
  color: var(--muted);
  font-size: .8125em;
}

.chart-legend li { display: flex; align-items: center; gap: .4rem; margin: 0; }

.key {
  display: inline-block;
  width: .9rem;
  height: .6rem;
  border: 1px solid var(--text);
}

.key.solid { background: var(--text); }

/* A line's key is a short stroke in the line's own style. */
.key.line {
  width: 1.4rem;
  height: 0;
  border: 0;
  border-top: 1.5px solid var(--text);
  background: none;
}

.key.line.dotted { border-top-style: dotted; border-top-width: 2px; opacity: .6; }
.key.line.thin { border-top-width: 1px; opacity: .6; }
.key.line.dashed { border-top-style: dashed; }
.key.line.solid { border-top-width: 3px; }
.key.line.faint { border-top-width: 1px; opacity: .35; }
.key.line.light { border-top-width: 1.5px; opacity: .55; }
.key.line.mid { border-top-width: 2px; opacity: .8; }

/* A line chart: one SVG, drawn by chart.js at the width it is given. */
.chart-plot.lines {
  display: block;
  cursor: crosshair;
  touch-action: pan-y;   /* a finger across it reads the hours; the page still scrolls */
}

.chart-plot.lines:focus-visible { outline: 1px solid var(--focus); outline-offset: 4px; }
.chart-plot.lines svg { display: block; overflow: visible; }

.chart-plot .grid line { stroke: var(--rule); stroke-opacity: .15; }
.chart-plot text {
  fill: var(--muted);
  font-size: 11px;
  font-variant-numeric: tabular-nums;
}

.chart-plot .mark line { stroke: var(--text); stroke-opacity: .5; stroke-dasharray: 4 3; }
.chart-plot .event line { stroke: var(--text); stroke-opacity: .5; }

.chart-plot .series { fill: none; stroke: var(--text); stroke-linejoin: round; stroke-linecap: round; }
.chart-plot .series.dotted { stroke-width: 1.5; stroke-dasharray: 0 3.5; stroke-opacity: .6; }
.chart-plot .series.thin { stroke-width: 1; stroke-opacity: .6; }
.chart-plot .series.dashed { stroke-width: 1.5; stroke-dasharray: 5 3.5; stroke-linecap: butt; }
.chart-plot .series.solid { stroke-width: 2.75; }
/* A scale of ink, for lines that are one thing turned up: faint to solid. */
.chart-plot .series.faint { stroke-width: 1.25; stroke-opacity: .35; }
.chart-plot .series.light { stroke-width: 1.5; stroke-opacity: .55; }
.chart-plot .series.mid { stroke-width: 2; stroke-opacity: .8; }

.chart-plot .cross { stroke: var(--text); stroke-width: 1; opacity: 0; }
.chart-plot .cross.on { opacity: .8; }

.chart-axis {
  margin: 0 0 .3rem;
  color: var(--muted);
  font-size: .75em;
  text-align: right;
}

/* One grid for every row, so the labels take the width of the longest and
   the bars all start at one line. */
.chart-plot {
  display: grid;
  grid-template-columns: fit-content(12rem) 1fr;
  column-gap: .75rem;
}

.chart-row { display: contents; }

.chart-label,
.chart-bars {
  padding: .3rem 0;
  border-top: 1px solid color-mix(in srgb, var(--rule) 18%, transparent);
}

.chart-label {
  display: flex;
  align-items: center;
  font-size: .8125em;
  line-height: 1.3;
}

.chart-bars { display: flex; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; }

.bar {
  appearance: none;
  display: flex;
  align-items: center;
  gap: .4rem;
  width: 100%;
  margin: 0;
  padding: 1px 0;
  border: 0;
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: .75rem;
  font-size-adjust: var(--x-height);   /* the UA button font shorthand resets this */
  font-variant-numeric: tabular-nums;
  text-align: left;
  cursor: pointer;
  transition: opacity .2s;
}

.bar-fill {
  flex: none;
  box-sizing: border-box;
  /* Room is kept at the end for the value, so the longest bar still fits. */
  width: calc(var(--v, 0) * (100% - 3.25rem));
  height: .75rem;
  border: 1px solid var(--text);
  transition: width .5s cubic-bezier(.3, .7, .2, 1);
}

.bar.solid .bar-fill { background: var(--text); }
.bar-value { color: var(--muted); }

.pointing .bar { opacity: .3; }
.pointing .bar.on { opacity: 1; }
.bar:focus-visible { outline: 1px solid var(--focus); outline-offset: 1px; }

.chart-readout {
  min-height: 3.3em;   /* two lines, so the page doesn't jump as it changes */
  margin: .7rem 0 0;
  color: var(--muted);
  font-size: .8125em;
}

/* The site name is the way home on a project page. */
.name a { text-decoration: none; }
.name a:hover { text-decoration: underline; text-decoration-thickness: 1px; }

/* =========================================================================
   Colophon
   ========================================================================= */

.colophon {
  margin-top: 4.5rem;
  padding-top: 1.75rem;
  border-top: 1px solid var(--rule);
  color: var(--faint);
  font-size: .8125em;
}

.colophon:empty { display: none; }

/* =========================================================================
   Small screens
   ========================================================================= */

@media (max-width: 34rem) {
  .masthead { flex-direction: column; gap: 1.25rem; }
  /* The masthead stacks here, so flex-grow would stretch the switches
     down the page instead of across it. */
  .controls { flex: 0 0 auto; align-self: stretch; }
  .theme-group { flex-wrap: wrap; }

  /* Too narrow for two columns. The note runs on after the text instead,
     so the entry reads as one sentence and keeps the full width. */
  li.row { display: block; }
  li.row .item-main,
  li.row .item-meta { display: inline; margin-left: 0; }
  li.row .item-meta::before { content: " \00B7  "; }

  /* Leave the bars room: long labels wrap sooner, the switches tighten. */
  .chart-plot { grid-template-columns: fit-content(8.5rem) 1fr; }
  .chart-controls .switch button { padding: .3rem .5rem; }

  /* A row of images would be too small side by side here; stack them. */
  .figure.row { flex-direction: column; }
  .figure.row > * { flex: none; }
}

@media (prefers-reduced-motion: reduce) {
  body.ready, body.ready * { transition: none !important; }
}

/* =========================================================================
   Fun — five drifting blobs the pointer shoves aside, under film grain
   =========================================================================
   Each blob is its own element so it can carry two motions at once. The
   CSS animation below owns `transform`, which is the slow independent
   drift. app.js owns the separate `translate` property, which is the shove
   away from the pointer. The two compose, so neither has to know about the
   other. */

:root[data-theme="fun"] body {
  position: relative;
  z-index: 0;   /* a stacking context, so the blobs sit above the page
                   background and below the type */
}

.blobs { display: none; }

:root[data-theme="fun"] .blobs {
  display: block;
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  overflow: hidden;
  opacity: .55;   /* keeps the colour pastel, so black type stays readable */

  /* --base is the same hue as the pools, pale, so the field is colour all
     the way out rather than pools floating on white. */
  background-color: var(--base, #f2eef4);
}

/* The pools are clipped to a circle centred on the last switch pressed
   (--px, --py, set by app.js). In "field" it is wider than the screen; in
   "source" it is nothing, so the page stays white and the marks on the
   trail carry the colour instead. Switching eases between the two, so the
   colour gathers into the button or spreads back out from it. */
.pools {
  position: absolute;
  inset: 0;
  clip-path: circle(120vmax at var(--px, 50%) var(--py, 50%));
  transition: clip-path 1.1s cubic-bezier(.65, 0, .25, 1);
}

:root[data-fun="source"] .pools { clip-path: circle(0 at var(--px, 50%) var(--py, 50%)); }

:root[data-theme="fun"] .blobs { transition: background-color 1.1s ease; }
:root[data-fun="source"] .blobs { background-color: transparent; }

/* Where the pointer has been. app.js redraws this every frame from the
   marks still alive, each opening out and fading. It sits inside the field,
   above the pools. In "field" the marks are white, so they cancel the
   colour and the page shows through; in "source" they are the colour. */
.trail {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* The five colours are written by app.js: one hue and its neighbours,
   turned by the pointer. The values here are a sky set, used until it runs. */
:root {
  --c1: #7fc4e6; --c2: #86c3ef; --c3: #93c0f4; --c4: #a4bcf5; --c5: #b6b7f2;
}

/* Each is an attractor point: a wide, soft pool of one colour, overlapping
   the others enough that they read as one blended field. They are wider
   than the viewport on purpose, so an edge never shows. app.js moves them
   by writing `translate`; nothing here animates. */
.blob {
  position: absolute;
  left: 50%;
  top: 50%;
  border-radius: 50%;
  will-change: translate;
}

.blob:nth-child(1) {
  width: 70vmax; height: 70vmax; margin: -35vmax 0 0 -35vmax;
  background: radial-gradient(closest-side, var(--c1), transparent);
}
.blob:nth-child(2) {
  width: 78vmax; height: 78vmax; margin: -39vmax 0 0 -39vmax;
  background: radial-gradient(closest-side, var(--c2), transparent);
}
.blob:nth-child(3) {
  width: 86vmax; height: 86vmax; margin: -43vmax 0 0 -43vmax;
  background: radial-gradient(closest-side, var(--c3), transparent);
}
.blob:nth-child(4) {
  width: 74vmax; height: 74vmax; margin: -37vmax 0 0 -37vmax;
  background: radial-gradient(closest-side, var(--c4), transparent);
}
.blob:nth-child(5) {
  width: 64vmax; height: 64vmax; margin: -32vmax 0 0 -32vmax;
  background: radial-gradient(closest-side, var(--c5), transparent);
}

/* Film grain. A tile of SVG noise, multiplied over everything including the
   type, nudged to a new offset a few times a second so it crawls. */
:root[data-theme="fun"] body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  opacity: .16;
  mix-blend-mode: multiply;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23g)'/%3E%3C/svg%3E");
  background-size: 200px 200px;
  animation: fun-grain .8s steps(1) infinite;
}

@keyframes fun-grain {
  0%   { background-position:   0px    0px; }
  25%  { background-position: -42px   18px; }
  50%  { background-position:  27px  -33px; }
  75%  { background-position: -19px  -11px; }
  100% { background-position:  24px   36px; }
}

/* A shallow roll through the text, like heat off a surface. The noise is
   static, but the filter runs again over all of main whenever anything in
   it repaints, which is every time a word is shoved. Touch screens go
   without it (see the end of this section). */
:root[data-theme="fun"] main {
  filter: url("#fun-warp");
}

/* The hand the animals, the water and the splash are drawn in: round caps
   and a small wobble from the #ink-sketch filter in the page's own
   <svg class="filters">, so the strokes read as pencil rather than vector. */
.ink {
  stroke-linecap: round;
  stroke-linejoin: round;
  filter: url(#ink-sketch);
}

/* Every word in the page body becomes one of these while Fun is on, so the
   pointer can shove them around.

   These stay `display: inline` on purpose. Making them inline-block would
   allow transforms, and rotation with them, but a browser may break a line
   between two atomic inlines even with a word joiner between them, which
   orphaned punctuation that followed a link. A relative offset moves an
   inline box without touching layout or line breaking at all. */
.shard {
  position: relative;
  transition: left .42s cubic-bezier(.22, .8, .3, 1),
              top .42s cubic-bezier(.22, .8, .3, 1);
}

@media (prefers-reduced-motion: reduce) {
  .shard { transition: none; }
}

/* A click sends a little animal off the pointer: it hops, tucks, spins
   once and lands bottom-first in the page, leaving a heave of water, a
   burst of splash strokes and a few droplets behind, then surfaces in the
   row along the bottom of the window. Drawn in ink rather
   than in the palette, so it reads against the colour, and doodled rather
   than drafted: the strokes are set in assets/app.js. */

.diver {
  position: fixed;
  z-index: 3;
  pointer-events: none;
  color: var(--text);
  animation: dive-across var(--flight, .95s) linear forwards;
}

.diver i {
  position: relative;
  display: block;
  width: calc(44px * var(--size, 1));
  height: calc(48px * var(--size, 1));
  margin: calc(-1.6rem * var(--size, 1)) 0 0 calc(-1.4rem * var(--size, 1));
  transform-origin: 50% 55%;
  animation: dive-fall var(--flight, .95s) forwards;
}

/* The standing bear and the tucked one share the box and cross-fade at
   the top of the hop. */
.diver svg {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

.diver .stand { animation: show-stand var(--flight, .95s) linear forwards; }
.diver .tuck  { opacity: 0; animation: show-tuck var(--flight, .95s) linear forwards; }

@keyframes dive-across {
  from { translate: 0 0; }
  to   { translate: var(--dx, 60px) 0; }
}

/* Up off the pointer, hang, then away down, turning over once on the way.
   The timing function set inside a keyframe governs the segment that
   starts there. The whole flight lasts --flight, set per dive from how far
   there is to fall, and the splash below is delayed by the same amount. */
@keyframes dive-fall {
  0% {
    transform: translateY(0) rotate(0deg);
    animation-timing-function: cubic-bezier(.16, .7, .45, 1);
  }
  34% {
    transform: translateY(-58px) rotate(40deg);
    animation-timing-function: cubic-bezier(.5, 0, .8, .45);
  }
  93%  { opacity: 1; }
  100% { transform: translateY(var(--dy, 170px)) rotate(360deg); opacity: 0; }
}

@keyframes show-stand {
  0%, 24%   { opacity: 1; }
  34%, 100% { opacity: 0; }
}

@keyframes show-tuck {
  0%, 24%   { opacity: 0; }
  34%, 100% { opacity: 1; }
}

/* The water. Its two halves draw themselves out from the landing point as
   the bear goes under, the whole line heaves up and settles, then fades. */
.splash {
  position: fixed;
  z-index: 3;
  width: 6.6rem;
  height: 1.25rem;
  margin: -.62rem 0 0 -3.3rem;
  color: var(--text);
  pointer-events: none;
  opacity: 0;
  transform-origin: 50% 50%;
  animation: splash-show 1.15s linear forwards;
  animation-delay: calc(var(--flight, .95s) - .07s);
}

.splash svg,
.flicks svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

.splash path {
  stroke-dasharray: 100;
  stroke-dashoffset: 100;
  animation: splash-draw .4s cubic-bezier(.2, .7, .3, 1) forwards;
  animation-delay: calc(var(--flight, .95s) - .07s);
}

@keyframes splash-draw {
  to { stroke-dashoffset: 0; }
}

@keyframes splash-show {
  0%   { opacity: 1; transform: scale(.9, 1); }
  12%  { opacity: 1; transform: scale(1, 1.7); }
  24%  { opacity: 1; transform: scale(1, .8); }
  36%  { opacity: 1; transform: scale(1, 1.15); }
  50%  { opacity: 1; transform: scale(1, 1); }
  100% { opacity: 0; transform: translateY(3px) scale(1.08, 1); }
}

/* The splash strokes, flying up and out of the landing and gone. */
.flicks {
  position: fixed;
  z-index: 3;
  width: 7.2rem;
  height: 3.3rem;
  margin: -3.2rem 0 0 -3.6rem;
  color: var(--text);
  pointer-events: none;
  opacity: 0;
  transform-origin: 50% 100%;
  animation: flick .6s cubic-bezier(.2, .7, .35, 1) forwards;
  animation-delay: calc(var(--flight, .95s) - .05s);
}

@keyframes flick {
  0%   { opacity: 1; transform: translateY(6px) scale(.25); }
  45%  { opacity: 1; transform: translateY(-10px) scale(1); }
  100% { opacity: 0; transform: translateY(-20px) scale(1.1); }
}

.drop {
  position: fixed;
  z-index: 3;
  width: .44rem;
  height: .44rem;
  margin: -.22rem 0 0 -.22rem;
  background: var(--text);
  border-radius: 50%;
  pointer-events: none;
  opacity: 0;
  animation: drop-arc .7s cubic-bezier(.3, .55, .7, 1) forwards;
}

@keyframes drop-arc {
  0%   { opacity: 1; transform: translate(0, 0) scale(1); }
  45%  { opacity: 1; transform: translate(calc(var(--ddx, 20px) * .6), var(--ddup, -26px)) scale(.9); }
  100% { opacity: 0; transform: translate(var(--ddx, 20px), 8px) scale(.45); }
}

/* Afloat along the bottom. Each animal rises where it landed, at the size
   it dived at, then bobs on its own wave at its own pace; `left` is set by
   assets/app.js. */
.swimmer {
  position: fixed;
  z-index: 3;
  bottom: 4px;
  width: calc(44px * var(--size, 1));
  height: calc(33px * var(--size, 1));
  color: var(--text);
  pointer-events: none;
  transition: left .6s cubic-bezier(.22, .8, .3, 1), opacity .5s ease;
  animation: surface .7s cubic-bezier(.2, .9, .3, 1.15) both;
}

.swimmer.gone { opacity: 0; }

.swimmer svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  animation: bob var(--bob-time, 3.2s) ease-in-out var(--bob-delay, 0s) infinite;
}

/* The rise uses `transform`, not `translate`: the swimming in app.js sets
   `translate` inline, and a filled animation on the same property would
   override it for good. */
@keyframes surface {
  from { transform: translateY(40px); opacity: 0; }
  to   { transform: translateY(0); opacity: 1; }
}

@keyframes bob {
  0%, 100% { translate: 0 0; rotate: -2deg; }
  50%      { translate: 0 -3px; rotate: 2deg; }
}

/* Touch screens. The warp is too small to see at arm's length and costs a
   CPU filter pass over the whole page on every repaint; the crawling grain
   repaints the whole screen five times a second. Both hold still here. */
@media (hover: none) and (pointer: coarse) {
  :root[data-theme="fun"] main { filter: none; }
  :root[data-theme="fun"] body::after { animation: none; }
}

@media (prefers-reduced-motion: reduce) {
  :root[data-theme="fun"] body::after { animation: none; }
  .blob { animation: none; }
  .pools, :root[data-theme="fun"] .blobs, .fun-controls { transition: none; }
  .thumb.hop { animation: none; }
  .diver, .splash, .flicks, .drop, .swimmer { display: none; }
}

/* =========================================================================
   Print — a clean CV falls out of this for free
   ========================================================================= */

@media print {
  :root { --bg: #fff; --text: #000; --muted: #444; --faint: #666; --rule: #ccc; }
  .controls { display: none; }
  body::after,
  .blobs,
  .swimmer { display: none; }
  main { filter: none; }
  .for-dark { display: none !important; }
  .for-light { display: block !important; }
  .page { max-width: none; padding: 0; }
  a { text-decoration: none; }
}
