/* ---------------------------------------------------------------
   Departure Mono
   --------------------------------------------------------------- */
@font-face {
  font-family: "Departure Mono";
  src: url("fonts/DepartureMono-Regular.woff2") format("woff2"),
    url("fonts/DepartureMono-Regular.woff") format("woff");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* ---------------------------------------------------------------
   Tokens — OKLCH. Light theme. The neutrals carry a trace of the
   accent hue (C=0.006–0.012) so the greys sit in the same family as
   the pink rather than reading cold against it; the accent itself
   stays well inside sRGB so nothing clips. Every text token clears
   APCA Lc 60 against --bg.
   --------------------------------------------------------------- */
:root {
  --bg: oklch(0.984 0.006 350);
  /* #fdf8fa           */
  --fg: oklch(0.205 0.008 350);
  /* #1a1618  Lc 101.2 */
  --fg-body: oklch(0.34 0.01 350);
  /* #3c3639  Lc 93.7  */
  --fg-muted: oklch(0.47 0.012 350);
  /* #60585c  Lc 80.4  */
  --accent: oklch(0.58 0.15 355);
  /* #bb4e7f  Lc 68.3  */
  --accent-hover: oklch(0.47 0.16 355);
  /* #9a265f  Lc 81.3  */
  /* Ink, not white: a light-on-light rule is invisible, so hairlines
     and code blocks tint the page down instead of up. */
  --rule: oklch(0.205 0.03 350 / 0.12);

  /* Type ramp */
  --text: clamp(13px, 3.2vw, 16px);
  --text-name: clamp(15px, 3.8vw, 19px);
  --emoji-size: min(8vw, 72px);
  --emoji-lh: 1.374;
  --emoji-gap: min(4vw, 36px);

  /* Also the clearance under the last line when the page scrolls past the
     fixed corner link, so the floor cannot drop below that link's height. */
  --pad-y: clamp(48px, 6vh, 64px);
  --column: min(90vw, 560px);
  --edge-x: clamp(24px, 4vw, 40px);
  --edge-y: clamp(20px, 3.5vh, 32px);

  --ease: cubic-bezier(0.2, 0, 0, 1);
}

/* ---------------------------------------------------------------
   Base
   --------------------------------------------------------------- */
*,
*::before,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html {
  background: var(--bg);
  color-scheme: light;
}

body {
  background: var(--bg);
  color: var(--fg-body);
  font-family: "Departure Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--text);
  line-height: 1.5;
  letter-spacing: -0.04em;
  /* No `-webkit-font-smoothing: antialiased` here. It thins strokes, which
     helps light text on a dark field and hurts dark text on a light one —
     this face is already hairline at 400, so leave the default in place. */
  min-height: 100svh;
  display: flex;
  justify-content: center;
  /* `safe` keeps the top of the column reachable when the content is
     taller than the viewport — plain `center` overflows in both
     directions and puts the first lines above the scroll origin. */
  align-items: center;
  align-items: safe center;
  transition: opacity 420ms ease-out;
}

/* transition.js sets .js on <html> before first paint, so the page
   starts hidden and fades in. Without JS the rule never applies and
   the content is visible immediately. */
.js body {
  opacity: 0;
}

::selection {
  background: var(--accent);
  color: var(--bg);
}

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

/* ---------------------------------------------------------------
   Column
   --------------------------------------------------------------- */
.container {
  width: 100%;
  max-width: var(--column);
  /* Symmetric, so the block centres optically rather than sitting low. */
  padding: var(--pad-y) var(--edge-x);
}

.emoji {
  font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
  font-size: var(--emoji-size);
  line-height: var(--emoji-lh);
  margin-bottom: var(--emoji-gap);
  user-select: none;
}

.name {
  color: var(--fg);
  display: inline-block;
  /* One step up from body text — enough to lead the card without a
     second weight, since the face only ships at 400. */
  font-size: var(--text-name);
  font-weight: 400;
  line-height: 1.5;
}

a.name {
  color: var(--fg-muted);
  text-decoration: none;
  transition: color 200ms var(--ease);
}

a.name:hover {
  color: var(--fg);
}

/* ---------------------------------------------------------------
   Bio
   --------------------------------------------------------------- */
.bio {
  margin-top: clamp(18px, 3.6vh, 30px);
}

.bio p {
  color: var(--fg-body);
  text-wrap: pretty;
}

.bio p+p {
  margin-top: clamp(12px, 2.4vh, 22px);
}

.bio a,
.note-body a {
  color: var(--accent);
  text-decoration: none;
  text-underline-offset: 3px;
  transition: color 200ms var(--ease);
}

.bio a:hover,
.note-body a:hover {
  color: var(--accent-hover);
  text-decoration: underline;
}

/* ---------------------------------------------------------------
   About — the same hairline list as the notes, minus the folding.
   Nothing here opens, so a row is a title and one line under it.
   --------------------------------------------------------------- */
.section {
  margin-top: clamp(26px, 5vh, 40px);
  color: var(--fg-muted);
  /* The face ships at 400 only, so a heading is set apart by colour
     and spacing rather than by weight or size. */
  font-size: var(--text);
  font-weight: 400;
}

.entries {
  margin-top: clamp(8px, 1.6vh, 14px);
  border-top: 1px solid var(--rule);
}

.entry {
  border-bottom: 1px solid var(--rule);
  padding: 14px 0;
}

.entry-title {
  color: var(--fg);
}

.entry-note {
  display: block;
  margin-top: 4px;
  color: var(--fg-muted);
  text-wrap: pretty;
}

/* ---------------------------------------------------------------
   Notes
   --------------------------------------------------------------- */
.notes {
  margin-top: clamp(18px, 3.6vh, 30px);
  border-top: 1px solid var(--rule);
}

.note {
  border-bottom: 1px solid var(--rule);
}

.note-summary {
  display: flex;
  align-items: baseline;
  gap: 12px;
  /* Padding, not height, so the row clears 44px for touch while the
     text stays optically snug against the rules. */
  padding: 14px 0;
  cursor: pointer;
  color: var(--fg-muted);
  list-style: none;
  transition: color 200ms var(--ease);
}

.note-summary::-webkit-details-marker {
  display: none;
}

.note-summary::marker {
  content: "";
}

.note-summary:hover,
.note[open]>.note-summary {
  color: var(--fg);
}

.chevron {
  flex: none;
  width: 1em;
  height: 1em;
  /* Optical nudge: the glyph baseline sits above the chevron's
     visual centre, so drop it to line up with the title.
     `translate`, not `transform`: the individual properties compose as
     translate * rotate * scale * transform, so a nudge written into
     `transform` sits inside the 180deg flip and inverts when the note
     opens, lifting the chevron off the title's line. `translate` is
     applied outside the rotation and stays put. */
  translate: 0 0.17em;
  rotate: 0deg;
  transition: rotate 220ms var(--ease);
}

.note[open]>.note-summary .chevron {
  rotate: 180deg;
}

.note-title {
  /* Basis 0, not auto: flex decides line breaks from the hypothetical size,
     so an auto-sized long title would jump to its own line under the chevron
     once the row is allowed to wrap. Starting at 0 keeps it beside the
     chevron and lets it grow, wrapping as text rather than as a flex line. */
  flex: 1 1 0;
  min-width: 0;
  text-wrap: pretty;
}

.note-date {
  flex: none;
  font-variant-numeric: tabular-nums;
  color: var(--fg-muted);
}

/* Animated reveal: 0fr -> 1fr is an interruptible transition, so a
   fast second click retargets instead of restarting. notes.js adds
   .reveal only when it can drive it; the plain <details> works
   without it. */
.note-reveal {
  display: grid;
  grid-template-rows: 1fr;
}

.note-reveal>* {
  min-height: 0;
}

.js .note-reveal {
  grid-template-rows: 0fr;
  transition: grid-template-rows 140ms var(--ease);
}

.js .note[open]>.note-reveal {
  grid-template-rows: 1fr;
  transition-duration: 200ms;
}

.note-body {
  overflow: hidden;
  color: var(--fg-body);
}

.js .note-body {
  opacity: 0;
  translate: 0 -4px;
  transition: opacity 140ms ease-out, translate 140ms ease-out;
}

.js .note[open]>.note-reveal>.note-body {
  opacity: 1;
  translate: 0 0;
  transition-duration: 200ms;
}

.note-body> :first-child {
  padding-top: 2px;
}

.note-body> :last-child {
  padding-bottom: 18px;
}

.note-body p+p,
.note-body p+ul,
.note-body p+ol,
.note-body ul+p,
.note-body ol+p {
  margin-top: clamp(12px, 2.4vh, 22px);
}

.note-body ul,
.note-body ol {
  padding-left: 1.4em;
}

.note-body li+li {
  margin-top: 6px;
}

.note-body code {
  font-family: inherit;
  color: var(--fg);
  background: var(--rule);
  padding: 0.1em 0.35em;
  border-radius: 3px;
}

.note-body pre {
  background: var(--rule);
  padding: 12px 14px;
  border-radius: 6px;
  overflow-x: auto;
  margin-top: clamp(12px, 2.4vh, 22px);
}

.note-body pre code {
  background: none;
  padding: 0;
}

.note-body blockquote {
  border-left: 1px solid var(--rule);
  padding-left: 14px;
  color: var(--fg-muted);
  margin-top: clamp(12px, 2.4vh, 22px);
}

.notes-empty {
  margin-top: clamp(18px, 3.6vh, 30px);
  color: var(--fg-muted);
}

/* On a narrow screen the date and a long title fight over the same
   line and squeeze the title to a few characters per row. Drop the
   date underneath instead, aligned to the title's left edge. */
@media (max-width: 420px) {
  .note-summary {
    flex-wrap: wrap;
  }

  .note-date {
    flex-basis: 100%;
    margin-left: calc(1em + 12px);
    margin-top: 4px;
  }
}

/* ---------------------------------------------------------------
   Fixed corner link
   --------------------------------------------------------------- */
.corner {
  position: fixed;
  left: var(--edge-x);
  bottom: var(--edge-y);
  z-index: 2;
}

/* A hairline frame, so the link reads as a control rather than as a
   stray line of body text sitting in the corner. */
.corner a {
  position: relative;
  display: inline-flex;
  align-items: center;
  padding: 7px 12px;
  color: var(--fg-muted);
  text-decoration: none;
  border: 1px solid var(--rule);
  border-radius: 4px;
  background: transparent;
  transition: color 200ms var(--ease),
    border-color 200ms var(--ease),
    scale 120ms var(--ease);
}

/* The frame is shorter than a comfortable touch target at the small end
   of the type ramp. Grow the hit area without growing the box. */
.corner a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  translate: 0 -50%;
  height: 44px;
}

/* The link only ever renders on the home page, so hover is the one
   state the frame has to carry. */
.corner a:hover {
  border-color: var(--accent);
}

.corner a:active {
  scale: 0.96;
}

/* The global :focus-visible rule sets border-radius: 2px, which would
   pinch this box's corners on keyboard focus. Hold the box radius; the
   outline offsets outward from it. */
.corner a:focus-visible {
  border-radius: 4px;
}

/* Keeps the link legible if the column ever grows tall enough to
   scroll underneath it. Invisible when the page fits. */
.corner::before {
  content: "";
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  height: calc(var(--edge-y) * 2 + 44px);
  background: linear-gradient(to top, var(--bg) 45%, transparent);
  pointer-events: none;
  z-index: -1;
}

/* ---------------------------------------------------------------
   Sideways phone — the vertical centring has no room to work.
   --------------------------------------------------------------- */
.rotate-notice {
  display: none;
}

@media (orientation: landscape) and (max-height: 500px) {

  .container,
  .corner {
    display: none;
  }

  .rotate-notice {
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--bg);
  }

  .rotate-notice .emoji {
    margin-bottom: 0;
    font-size: 56px;
    line-height: 1;
  }
}

/* ---------------------------------------------------------------
   Reduced motion — everything still works, nothing moves.
   --------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  .js body {
    opacity: 1;
  }

  body,
  .chevron,
  .js .note-reveal,
  .js .note-body,
  a,
  .note-summary {
    transition: none !important;
  }

  .js .note-reveal {
    grid-template-rows: 1fr;
  }

  .js .note:not([open])>.note-reveal {
    grid-template-rows: 0fr;
  }

  .js .note-body {
    opacity: 1;
    translate: none;
  }

  .corner a:active {
    scale: none;
  }
}

/* ---------------------------------------------------------------
   Narrow screens — centre the text, not just the column.

   The column is centred at every width, but under ~640px it is the
   screen: `min(90vw, 560px)` leaves a gutter too thin to read as
   centring, so the block goes flush-left and ragged-right and the
   card stops looking centred the way it does on a wide viewport.
   Centre the text itself instead. The notes list stays left-aligned —
   a chevron/title/date row is a table, not a caption — and the corner
   link stays pinned bottom-left.
   --------------------------------------------------------------- */
@media (max-width: 640px) {
  .container {
    text-align: center;
  }

  /* Short centred lines read better balanced than ragged. */
  .bio p {
    text-wrap: balance;
  }

  /* A heading centred over a left-aligned list reads as a stray line,
     so the About section keeps its heading with its rows. */
  .notes,
  .note-body,
  .section,
  .entries {
    text-align: left;
  }
}

/* ---------------------------------------------------------------
   The dog

   A 6x4 sprite sheet of 128px frames (32px of pixel art at 4x).
   Rows: 0 walk, 1 sit, 2 idle, 3 love. Every row is six frames,
   which is why one shape of keyframe covers all four.

   --f is the rendered frame size and the only thing that differs
   between the cursor-follower and the inline dog, so the sheet
   geometry is written in terms of it and stays pixel-exact at
   both sizes: 64 and 32 both divide 128 evenly, so no frame ever
   lands on a half pixel.

   The keyframes have to live here rather than being injected from
   dog.js, because style-src is 'self' with no 'unsafe-inline'.
   --------------------------------------------------------------- */
.dog {
    /* One size everywhere: the dog that walks the page and the dog parked
       beside the name are the same animal, and he starts the page next to the
       name, so a difference would show. */
    --f: 48px;
    width: var(--f);
    height: var(--f);
    background-image: url("/sprites/labrador-pink.png");
    background-size: calc(var(--f) * 6) calc(var(--f) * 4);
    background-repeat: no-repeat;
    /* Nearest-neighbour, or the pixel art turns to mush. */
    image-rendering: pixelated;
    /* He must never eat a click meant for a link. */
    pointer-events: none;
}

.dog--follow {
    position: fixed;
    left: 0;
    top: 0;
    /* Above .corner, which is the only other layered thing on the page. */
    z-index: 3;
    /* He is moved with transform rather than left/top, so give him his own
       compositor layer and keep his motion off the layout path. */
    will-change: transform;
}

/* The slot is sized to the dog's visible artwork, not to his sprite frame, so
   he costs the name's line box only 1em. Sizing the element to the frame
   instead grew the line by ~35px and shoved the heading down the page.

   The drawn dog occupies x 8..26, y 4..28 of his 32px cell, so at a 48px
   frame that is 29x38 of visible dog, inset 12px from the left and 4px from
   the bottom. Those four numbers are the whole of the positioning below. */
.dog-slot {
    position: relative;
    display: inline-block;
    width: 29px;
    height: 1em;
    vertical-align: baseline;
    margin-left: 0.5ch;
}

.dog--inline {
    position: absolute;
    /* His left edge lands at the slot's left and his paws on the text
       baseline. The heart in the love row overflows above, which is fine. */
    left: -12px;
    bottom: -4px;
}

/* The one part of him that takes a click. .dog is pointer-events: none so he
   can never swallow a click meant for a link, and this opts a single region
   back in -- inset to the drawn artwork rather than the full sprite frame, so
   the empty margin around him stays click-through.

   The insets are the artwork's (x 8..26, y 4..29 of the 32px cell) with the
   left and right made equal to the tighter of the two, because dog.js flips
   him with scaleX(-1) and an asymmetric box would jump sides when he turns.

   Sized in percentages so the same rule fits the follower and the inline dog
   whatever --f is. */
.dog-hit {
    position: absolute;
    left: 18.75%;
    right: 18.75%;
    top: 12.5%;
    bottom: 9.375%;
    pointer-events: auto;
    cursor: pointer;
    /* He is the affordance; the anchor itself is an invisible box. */
    -webkit-tap-highlight-color: transparent;
}

/* The art faces right, and he is turned around when heading left -- but the
   flip is composed into the transform dog.js writes, since position uses the
   same property. There is no .dog--flip rule for that reason. */

/* The duration here is a fallback only -- dog.js overrides it at runtime with
   a value derived from his travel speed, so the legs cannot drift out of sync
   with the ground the way they do when both numbers are set by hand. */
.dog--walk {
    animation: dog-walk 600ms steps(6) infinite;
}

/* One-shot: he stops, turns to face you and sits. `forwards` holds the
   seated frame, which is drawn identically to the first idle frame so
   the hand-off between the two does not pop. */
.dog--sit {
    animation: dog-sit 500ms steps(6) 1 forwards;
}

/* Also the heart's clock: a roll happens at each cycle boundary, so this
   doubles as how often he can think about you. */
.dog--idle {
    animation: dog-idle 1800ms steps(6) infinite;
}

.dog--love {
    animation: dog-love 1800ms steps(6) 1 forwards;
}

@keyframes dog-walk {
    from {
        background-position: 0 0;
    }

    to {
        background-position: calc(var(--f) * -6) 0;
    }
}

@keyframes dog-sit {
    from {
        background-position: 0 calc(var(--f) * -1);
    }

    to {
        background-position: calc(var(--f) * -6) calc(var(--f) * -1);
    }
}

@keyframes dog-idle {
    from {
        background-position: 0 calc(var(--f) * -2);
    }

    to {
        background-position: calc(var(--f) * -6) calc(var(--f) * -2);
    }
}

@keyframes dog-love {
    from {
        background-position: 0 calc(var(--f) * -3);
    }

    to {
        background-position: calc(var(--f) * -6) calc(var(--f) * -3);
    }
}

/* Sideways phone: the whole page is replaced by the rotate notice, and the
   inline dog goes with .container, but the fixed follower needs telling. */
@media (orientation: landscape) and (max-height: 500px) {
    .dog--follow {
        display: none;
    }
}

/* Reduced motion: he still shows up, he just stops moving. dog.js already
   forces inline mode here, so this only has to freeze him on the first
   idle frame -- the seated pose. */
@media (prefers-reduced-motion: reduce) {
    .dog {
        animation: none !important;
        background-position: 0 calc(var(--f) * -2);
    }
}
