/* palette.css - the colour system, shared by every theme.
 *
 * Colour is not a list of values here. It is one seed hue, one chroma and one
 * harmony rule; every other hue on the page is derived from those three in CSS.
 * That is what makes "show me this in its complementary palette" a single
 * number rather than a repaint: --spread changes, and every accent, every state
 * colour and every tint follows.
 *
 * The three inputs are written by the server into a <style id="design-palette">
 * element, so they can be swapped in place while the page is open.
 *
 * A theme reads the hue and chroma variables below and decides what lightness
 * to pair them with. That split is the contract: this file owns which hues are
 * related, the theme owns how bright and how heavy they are.
 */

:root {
	/* Overridden by the server. These are the fallbacks if it says nothing. */
	--hue: 255;
	--chroma: 0.035;
	--spread: 180;

	/* --------------------------------------------------------- matched hues -
	 *
	 * oklch() wraps hue angles, so 255 + 180 = 435 resolves to 75 with no
	 * arithmetic on our side and no discontinuity at the ends of the slider.
	 *
	 * --hue-match and --hue-counter follow the chosen harmony rule, so with
	 * Mono they collapse onto the seed and the page becomes monochrome, with
	 * Complement they sit opposite it, and with Triad they form the third.
	 * A theme that uses only these two gets a correct palette under every rule
	 * without knowing which rule is in force.
	 */
	--hue-seed: var(--hue);
	--hue-match: calc(var(--hue) + var(--spread));
	--hue-counter: calc(var(--hue) - var(--spread));

	/* Fixed relationships, for the cases where a theme wants a specific one
	 * rather than whatever the current rule produces. */
	--hue-complement: calc(var(--hue) + 180);
	--hue-warm: calc(var(--hue) - 30);
	--hue-cool: calc(var(--hue) + 30);

	/* ------------------------------------------------------------- chroma  -
	 *
	 * One dial drives the whole page's saturation. Surfaces get a trace of the
	 * seed so the neutrals are never dead grey; accents get a multiple so they
	 * stay accents even when the dial is low. At chroma 0 the entire site is
	 * genuinely achromatic, which is a legitimate choice rather than a
	 * degenerate one.
	 */
	--chroma-ghost: calc(var(--chroma) * 0.22);
	--chroma-quiet: calc(var(--chroma) * 0.55);
	--chroma-full: var(--chroma);
	--chroma-accent: calc(var(--chroma) * 2.6);
	--chroma-loud: calc(var(--chroma) * 3.6);

	/* --------------------------------------------------------- state hues  -
	 *
	 * Danger and success are absolute, not derived: a red that drifts to green
	 * because the seed moved is a lie about the state it reports. They still
	 * take the page's chroma so they sit in the same colour world.
	 */
	--hue-alert: 27;
	--hue-good: 148;

	/* -------------------------------------------------------------- motion -
	 *
	 * Appearance and animation are two controls, not one, and this is where that
	 * separation is actually enforced. A backdrop stylesheet says what it looks
	 * like and how it would move; whether any of that movement happens is
	 * decided here, by multiplying every distance by a flag the server wrote.
	 *
	 * That is why there is no still variant of any pattern. "Field, holding
	 * position" is not a second stylesheet - it is Field with --anim-ambient at
	 * zero, and every distance in it collapsing to nothing while the pattern,
	 * the colours and the layout stay exactly as they were.
	 *
	 * --motion is the amplitude under all of it: the speed and the distance both
	 * come off it, so "calmer" is one number rather than a re-timed set of
	 * keyframes. The floor in each divisor is what makes zero mean *slow*
	 * rather than *broken*: at 0 every duration becomes about half an hour,
	 * which is a frozen pattern rather than a division by nothing.
	 */
	--motion: 0.6;

	--motion-slow: calc(64s / max(var(--motion), 0.03));
	--motion-mid: calc(31s / max(var(--motion), 0.03));
	--motion-fast: calc(13s / max(var(--motion), 0.03));

	/* The three behaviours of the Animation axis, as multipliers. Overwritten by
	 * the server; the defaults are what a page gets if it says nothing, and they
	 * are the live pair rather than all three, because a backdrop that answers
	 * the pointer before anyone asked it to is a surprise. */
	--anim-ambient: 1;
	--anim-scroll: 1;
	--anim-react: 0;

	/* The same first flag as a play state. Multiplying the distances by zero
	 * already makes a still pattern; this is what stops the machine turning to
	 * produce it. Only ambient animations take it - a scroll-driven one has no
	 * clock of its own to stop. */
	--play-ambient: running;

	/* What a backdrop actually multiplies its distances by.
	 *
	 * Three, not one, because the three behaviours are independently switchable
	 * and a keyframe cannot ask which level is selected. A drift uses --travel,
	 * a scroll-linked transform uses --travel-scroll, and anything that leans
	 * towards the pointer uses --travel-react. Every one of them is the
	 * intensity dial times its own flag, so a level is off when its flag is
	 * zero and quiet when the dial is low, with no branch anywhere.
	 */
	--travel: calc(var(--motion) * var(--anim-ambient));
	--travel-scroll: calc(var(--motion) * var(--anim-scroll) * var(--scroll-gain));
	--travel-react: calc(var(--motion) * var(--anim-react));

	/* ------------------------------------------------------- the scroll axis -
	 *
	 * The three answers the scroll gets that the other behaviours do not need,
	 * all written by the server, all inert while --anim-scroll is zero.
	 *
	 * --scroll-dir is the sign, and it is the whole direction control: +1 sinks
	 * the pattern as the page goes down, -1 lifts it. Every backdrop states its
	 * depths as plain positive distances and multiplies by this, so no backdrop
	 * file has an opinion about which way is which and the two directions cannot
	 * drift apart.
	 *
	 * --scroll-gain is how far all of it travels, folded into --travel-scroll
	 * above so a backdrop spends it without naming it.
	 *
	 * --scroll-split is how much the depths are allowed to disagree: 0 collapses
	 * every layer onto one plane, 1 is the spread the backdrop was drawn with, 2
	 * is twice it. It is the one number that decides whether a backdrop reads as
	 * a scene with depth or as a flat sheet sliding past.
	 */
	--scroll-dir: 1;
	--scroll-gain: 1;
	--scroll-split: 1;

	/* ------------------------------------------------------------- pointer -
	 *
	 * Written by static/pointer.js, which is the only thing on the site that
	 * needs a script at all: CSS can express :hover but cannot say where the
	 * pointer is.
	 *
	 * The defaults here are what a page with no scripting keeps forever, and
	 * they are the reason nothing has to be guarded: --pointer at zero means
	 * every effect built on it is already multiplied out before the script
	 * either arrives or does not.
	 */
	--px: 0.5;
	--py: 0.5;
	--pointer: 0;

	/* The two levels of the Pointer control, as multipliers. Overwritten by
	 * the server; a stylesheet switches an effect off by multiplying by one of
	 * these rather than by knowing which level is selected. */
	--pointer-tint: 0;
	--pointer-wave: 0;

	/* How the tint keeps up with the pointer. Deliberately slow: a mark that
	 * snaps to the cursor is a cursor effect, one that drifts after it is the
	 * pattern noticing. The wave gets its own, longer clock. */
	--tint-follow: 700ms;
	--wave-period: calc(7s / max(var(--motion), 0.25));

	/* ------------------------------------------------------------- scroll -
	 *
	 * Where the page is scrolled to, 0 at the top and 1 at the bottom, written
	 * by static/backdrop.js for the same reason the pointer coordinates are
	 * written by pointer.js: a stylesheet has no way to ask. CSS scroll
	 * timelines exist, but only in one engine; one number written per frame is
	 * the version that works everywhere and costs nothing when still.
	 *
	 * The default is the top of the page, which is also what a page with no
	 * scripting keeps forever: every scroll-linked travel multiplies this, so
	 * it is already zeroed before the script arrives or does not.
	 */
	--scroll: 0;
}

/* Registered, so the engine knows --scroll is a number rather than a token
 * soup it has to re-parse everywhere it is spent. That is what makes it
 * animatable, which is what the scroll timeline below needs, and it is also
 * what keeps a page whose script never arrives at a real 0 rather than at an
 * invalid value that would make every calc() using it unset. */
@property --scroll {
	syntax: "<number>";
	inherits: true;
	initial-value: 0;
}

/* Where the scroll number comes from, when the engine can supply it itself.
 *
 * A number written from a scroll event is always at least one frame behind the
 * scrollbar, because the page scrolls on the compositor and the handler runs
 * after it: the backdrop arrives late, catches up in a jump, and what that
 * looks like is stutter. A scroll-driven animation is sampled from the actual
 * scroll offset of the frame being drawn, so the pattern and the text are never
 * out of step by a single pixel.
 *
 * Every engine that has shipped scroll timelines takes this path and
 * backdrop.js does not attach a listener at all; the ones that have not keep
 * the script's damped fallback. Either way the rest of the site sees one
 * number and knows nothing about where it came from.
 *
 * Under reduced motion neither path runs: the media query holds this off, the
 * script bows out on the same query, and skeleton.css removes the backdrop from
 * the animation system outright.
 */
@keyframes scroll-progress {
	from {
		--scroll: 0;
	}

	to {
		--scroll: 1;
	}
}

@media (prefers-reduced-motion: no-preference) {
	@supports (animation-timeline: scroll()) {
		:root {
			animation-name: scroll-progress;
			/* auto, not a time: on a progress-based timeline the effect lasts
			 * exactly one scrollport rather than one clock. */
			animation-duration: auto;
			animation-timing-function: linear;
			animation-fill-mode: both;
			animation-timeline: scroll(root block);
		}
	}
}

/* ------------------------------------------------------ the scroll geometry -
 *
 * How far one layer of the backdrop moves, computed once here rather than ten
 * times in ten backdrop files.
 *
 * A backdrop states two things and nothing else: --depth per layer, which is
 * how far that layer travels over a whole page at full intensity, and
 * --depth-flat on .backdrop, which is the one depth every layer collapses onto
 * when the separation dial is at zero. Everything after that - the dials, the
 * sign, the animation flags, the intensity - is spent here, so a backdrop can
 * be written without knowing any of them exist, and a new control never means
 * editing every pattern.
 *
 * --depth-flat is optional. A backdrop that omits it falls back to each layer's
 * own depth, which makes the separation dial a control that does nothing rather
 * than one that does something wrong.
 */
.backdrop__layer {
	/* Clamped at zero rather than allowed to go negative: past the point where a
	 * near layer's over-separation would push a far one below nothing, the far
	 * layer stops instead of turning round and travelling backwards, which would
	 * read as two backdrops fighting. */
	--depth-split: max(0px, calc(
		var(--depth-flat, var(--depth, 0vh)) +
		(var(--depth, 0vh) - var(--depth-flat, var(--depth, 0vh))) * var(--scroll-split)
	));

	/* The furthest this layer can be from where it was drawn, unsigned. A
	 * backdrop whose art has an edge uses it to extend past that edge by exactly
	 * as much as it can travel, so no direction and no dial setting can ever
	 * pull the edge into view. */
	--shift-max: calc(var(--depth-split) * var(--travel-scroll));

	/* Where this layer is now. One length, carrying the dials, the direction and
	 * the animation flag together; a backdrop spends it as a translation on
	 * whichever of transform or translate it has left free. */
	--shift: calc(var(--shift-max) * var(--scroll) * var(--scroll-dir));
}

/* ------------------------------------------------------------- the presets -
 *
 * The seeds behind the switcher's swatches, mirroring the registry in
 * internal/design. They live here rather than in the markup so a swatch can be
 * drawn with the same formula the page itself uses, and so no template has to
 * emit an inline colour.
 *
 * These are the one place a hue is written down by hand, because a preset is
 * by definition a chosen starting point.
 */
.swatch--graphite { --swatch-hue: 258; --swatch-chroma: 0.008; }
.swatch--slate    { --swatch-hue: 255; --swatch-chroma: 0.035; }
.swatch--ocean    { --swatch-hue: 225; --swatch-chroma: 0.062; }
.swatch--moss     { --swatch-hue: 148; --swatch-chroma: 0.052; }
.swatch--brass    { --swatch-hue: 78;  --swatch-chroma: 0.058; }
.swatch--clay     { --swatch-hue: 32;  --swatch-chroma: 0.055; }
.swatch--rose     { --swatch-hue: 355; --swatch-chroma: 0.05; }
.swatch--plum     { --swatch-hue: 312; --swatch-chroma: 0.05; }

/* ---------------------------------------------------------- the derived set -
 *
 * The kit's colour ramp, which is the palette explaining itself: one chosen hue
 * and five calculated from it. Only the mapping lives here, because which hue is
 * which is a palette fact; what lightness draws it is the theme's call.
 */
.kit-hue--seed       { --of: var(--hue-seed); }
.kit-hue--match      { --of: var(--hue-match); }
.kit-hue--counter    { --of: var(--hue-counter); }
.kit-hue--complement { --of: var(--hue-complement); }
.kit-hue--warm       { --of: var(--hue-warm); }
.kit-hue--cool       { --of: var(--hue-cool); }

/* ------------------------------------------------------------- the nearby -
 *
 * A viewport-wide light under the pointer, sampled by any element that paints
 * it. background-attachment: fixed is what makes that one light rather than one
 * per element: the gradient is positioned against the window, so each element
 * shows only the part of it that happens to overlap - which is exactly "things
 * light up as the pointer comes near" with no per-element maths and no script
 * beyond the two coordinates.
 *
 * The colour is left to the theme; only the shape and where it is belong here.
 */
/* Used as: background-image: var(--nearby-image); background-attachment: fixed;
 * opacity: var(--nearby-on); with the theme setting --nearby to a colour. */
:root {
	--nearby-image: radial-gradient(
		18rem circle at calc(var(--px) * 100vw) calc(var(--py) * 100vh),
		var(--nearby, transparent),
		transparent 100%
	);
	--nearby-on: calc(var(--pointer) * var(--pointer-tint));
}
