/* Shared chrome for every v2 page: ground, type defaults, header, nav,
   and the page heading. Page-specific styles live in their
   own file and load after this one. */

* { box-sizing: border-box; }

/* Two document-level guards, both of them about the hover caption on the
   projects board, and both of them here rather than on the board because the
   thing they hold still is the page, not the board.

   `overflow-anchor: none`. Opening a caption makes one tile taller, which
   makes the document taller. Scroll anchoring answers a document that changed
   height by moving the scroll position to hold whatever it picked as the
   anchor still, and when the anchor sits below the change, holding it still
   means scrolling down: the page slides up under a pointer that only hovered.
   The board excludes itself from anchor selection, which covers an anchor
   that is another tile; it does not cover one outside the board, and the
   adjustment is the same either way. Nothing on this site needs anchoring in
   the first place, because every image ships with `width` and `height` and so
   nothing above the fold resizes as it loads.

   `scrollbar-gutter: stable`. A page that is a little shorter than the window
   grows past it when a caption opens, so a classic scrollbar arrives, the
   viewport narrows by its width, and `js/board.js` repacks the whole board on
   the resize: every tile moves because one caption opened. Reserving the
   gutter means the width does not depend on whether the page happens to
   overflow. It costs nothing where scrollbars are overlays. */
html {
  overflow-anchor: none;
  scrollbar-gutter: stable;
}

/* The first thing in the tab order on every page. Off-screen until it takes
   focus, then it drops into the top-left corner as a real, solid control: a
   link that stays invisible while focused is worse than no link, because the
   reader is now on a control they cannot see. It is drawn on the ground rather
   than translucent, so it never has to be read through whatever it lands on. */
.skip-link {
  position: absolute;
  left: var(--s4);
  top: var(--s4);
  z-index: 100;
  padding: var(--s2) var(--s3);
  background: var(--ground);
  color: var(--ink);
  font-family: var(--sans);
  font-size: var(--text-label);
  text-decoration: none;
  border: 1px solid var(--frame-line);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lift);
  /* Out of the viewport rather than `display: none`, which would take it out
     of the tab order and leave nothing to skip with. */
  transform: translateY(calc(-100% - var(--s5)));
  transition: transform var(--dur-fast) var(--ease);
}

.skip-link:focus { transform: translateY(0); }

/* Read aloud, never drawn. For text that is the announcement of something the
   page has already said visually in a way a screen reader cannot follow: a word
   swapping in place, a mark moving. Clipped rather than `display: none`, which
   would take it out of the accessibility tree along with the layout. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

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

body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--sans);
  font-size: var(--text-sm);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

/* A column at least as tall as the viewport, so the foot line can be pushed
   to the bottom of a short page. `contact` and `404` both end well above the
   fold, and a closing line floating in the middle of the screen with a field
   of ground under it reads as the page having stopped rather than ended.
   `svh` rather than `vh` because on a phone `vh` is the tallest the viewport
   ever gets, which puts the line under the address bar until it retracts. */
.wrap {
  max-width: 900px;
  margin: 0 auto;
  /* The bottom inset is `--s5`, not `--s8` like the other edges. It is the
     distance from the foot line to the bottom of the window on a short page
     and to the end of the document on a long one, and those are one measure,
     so they get one number. At `--s8` the line read as floating short of the
     bottom rather than sitting at it. */
  padding: var(--s6) var(--s7) var(--s5);
  min-height: 100svh;
  display: flex;
  flex-direction: column;
}

/* Header. No rule under it. There was one, drawn as the track the current-page
   marker rode on, and it is exactly the divider under a navbar the design rules
   ban: whitespace separates the header from the page, and the marker marks the
   page on its own. With the rule gone the marker went back to the 2px it was
   before the rule arrived, since it no longer has to stand above a line.

   The row is aligned on the baseline, so the wordmark and the nav sit on one
   line of writing rather than on one box edge. They are different faces at
   different sizes, and matching their boxes left their letters half a pixel
   apart. The marker is drawn against this element, not against the nav, so it
   does not depend on how the row is aligned. */
.site-head {
  /* How far the marker sits below the row, and the only place that distance is
     written. It is padding rather than an offset on the marker, so the marker
     stays pinned to this element's bottom edge and only the writing moves up
     away from it. It was 8 here with a second -4px on the marker itself, which
     is two numbers for one gap and read as 12: far enough that the rule looked
     like it belonged to the page rather than to the word above it. 4, and the
     marker sits on the edge. The descenders clear it by about 9px, since the
     16px nav's 25.6px line box carries 4.8 of half-leading under the `j`. */
  --nav-gap: var(--s1);

  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--s5);
  padding-bottom: var(--nav-gap);
  margin-bottom: var(--s6);
  position: relative;
}


.wordmark {
  /* The gaps the animation opens and closes. They are declared here rather
     than left to the font's own space and quote glyphs, so the collapsed
     word is exactly flush in any font: the spacers are these widths and the
     translations are the same values negated. */
  /* Tighter than the font's own word space. The three words have to read as
     three, and at the mark's 22px a full space opened them far enough that the
     name stopped reading as one thing. This is the narrowest gap that still
     separates them. */
  --wm-sp: .10em;   /* the gap between words */
  /* The slot a quote sits in, and it has to be the glyph's own advance or the
     quote overlaps the letter beside it. .34em was Public Sans's; Gabarito's
     quote is wider, and at .34 the opening mark overprinted the k by three
     pixels while the closing one sat normally, which read as the closing one
     having drifted away from the e. */
  --wm-q: .475em;
  --wm-shift-1: calc(var(--wm-sp) * 2 + var(--wm-q));
  --wm-shift-2: calc(var(--wm-sp) + var(--wm-q));

  /* Its own face, not the serif the rest of the display type takes and not the
     sans the interface takes. The mark is the name as it is said, with the
     pronunciation built into it, so it is a logotype rather than a heading,
     and a logotype set in the body face is the site labelling itself in its
     own body copy. Revised 2026-09-14: it was Public Sans, which made it the
     one crossover in DESIGN.md's serif/sans split; now it is outside the split
     entirely. It is still sized and weighted above the nav so it outranks it:
     the nav is 16px at 400 in the interface sans. */
  position: relative;
  /* Optical, not geometric. The row is baseline-aligned and the baselines do
     meet: both sit on the same 42.5px. What reads as misaligned is the ink
     either side of that line. The mark's caps rise 15.9px above the baseline
     where the nav's rise 11.6, and nhilikeknee has no descender at all while
     the nav's `projects` drops a `j` 2.7px below, so the two blocks of ink
     center 3.4px apart with the mark high. The marker under the current page
     pulls the nav side lower again. 2px of that 3.4 is taken back here: enough
     that the row reads level, short of the 3 that would close the gap and
     flatten the size difference the baseline is there to express. It is `top`
     rather than a change of alignment so nothing in the row moves but the
     mark, and nothing in layout moves at all: the nav still descends furthest,
     so it still sets the header's bottom edge and the marker's position. */
  top: 2px;
  font-family: var(--mark);
  font-size: 22px;
  /* No leading under the mark. The header is baseline-aligned, so its bottom
     edge is set by whichever item descends furthest below that baseline, and the
     nav's marker is drawn from that edge. At the mark's 22px its half-leading
     pushed the header's bottom several pixels below the nav's, which left the
     marker floating well under the word it marks. Closing the leading makes
     the nav the lowest thing in the row, so the marker sits where it belongs. */
  line-height: 1;
  /* 600, not the 500 every other emphasised thing on the site takes. `body`
     sets `-webkit-font-smoothing: antialiased`, which thins glyphs by about a
     weight step on macOS, and the mark is the one place that shows: it is the
     only text on the page with nothing beside it at the same size to be
     measured against. 600 puts it back where 500 looks on an unsmoothed
     screen. */
  font-weight: 600;
  text-decoration: none;
  /* The display brown, the same as the heading below it. In near-black the
     mark read cold against a warm heading it sits directly above. */
  color: var(--ink-display);
  white-space: nowrap;
  animation: wm-fade var(--dur-fast) var(--ease) both;
}


.wm__sp { display: inline-block; width: var(--wm-sp); }
.wm__like,
.wm__tail { display: inline-block; color: var(--muted-display); }
.wm__q {
  display: inline-block;
  width: var(--wm-q);
  opacity: 0;
}

/* The entrance the v1 landing page gave the name, now that v2 has no landing
   page to give it. The name arrives as one word, nhilikeknee, holds there long
   enough to be read, splits into nhi likeknee, and then opens again for the
   quotes to drop in from above.

   It is pure CSS with `both` fill, so it runs on every load with no script
   and the final state is the base state: nothing can be stranded invisible
   if something fails. Only transform and opacity move, and the wordmark's
   box is the finished size throughout, so the nav never reflows. */
.wm__like { animation: wm-like var(--dur-wordmark) var(--ease) both; }
.wm__tail { animation: wm-tail var(--dur-wordmark) var(--ease) both; }
.wm__q    { animation: wm-quote var(--dur-wordmark) var(--ease) both; }
.wm__q--close { animation-delay: 110ms; }

@keyframes wm-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* The name holds as one word for the first 42%, which is about 550ms: long
   enough to be read as nhilikeknee before anything moves. */

/* nhilikeknee -> nhi likeknee */
@keyframes wm-like {
  0%, 42%   { transform: translateX(calc(-1 * var(--wm-sp))); }
  60%, 100% { transform: translateX(0); }
}

/* nhilikeknee -> nhi likeknee -> nhi like "knee" */
@keyframes wm-tail {
  0%, 42%   { transform: translateX(calc(-1 * var(--wm-shift-1))); }
  60%, 69%  { transform: translateX(calc(-1 * var(--wm-shift-2))); }
  90%, 100% { transform: translateX(0); }
}

/* The quotes drop in from above as the gap opens for them. Their translate
   composes with the tail's, so they arrive while the tail is still sliding. */
@keyframes wm-quote {
  0%, 69%   { opacity: 0; transform: translateY(-.45em); }
  90%, 100% { opacity: 1; transform: translateY(0); }
}

/* Still `relative`, though nothing inside it is positioned against it any
   more: the marker moved onto the header. Dropping it meant a cached copy of
   the old script could append the marker here and have it resolve against the
   header instead, which put the bar under the wordmark. */
.site-nav { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s5); align-items: baseline; position: relative; }
/* The nav is interface, not display, so it sits in the sans with the filter
   pills and every other control. The serif carries headings and titles;
   the wordmark is the one sans crossover, and it clears the nav by size and
   weight rather than by face. */
.site-nav a {
  font-family: var(--sans);
  font-size: var(--text-sm);
  color: var(--muted);
  text-decoration: none;
  white-space: nowrap;
  position: relative;
}

/* Hover darkens the word. It was a warm plate behind it, which at the width
   the plates ended up needing read as a tab strip with a tab lit up, and that
   is the rule below's job: the rule means "you are here" and must not be
   competing with "you are pointing at this".

   Color only, and only a color that is already on the page. Nothing moves
   and nothing reflows, which a weight change would do: bolding one word in a
   row of four shifts the three beside it, and the measured rule under the
   current page would be left pointing at where a word used to be. */
.site-nav a {
  transition: color var(--dur-fast) var(--ease);
}

.site-nav a:hover,
.site-nav a:focus-visible { color: var(--ink-display); }

/* The current page is the display brown too, so hovering another tab brings it
   to the same weight of color as the one you are on. What separates them is
   the rule, which is the whole point of having one. */
.site-nav a[aria-current="page"] { color: var(--ink-display); }

/* The plate was doing the focus ring's job as well. With it gone the ring has
   to be drawn: an outline rather than a background, so it is visible against
   whatever the word is sitting on. */
.site-nav a:focus-visible {
  outline: 2px solid var(--ink-display);
  outline-offset: 4px;
  border-radius: var(--radius-sm);
}

/* The resume is not a page, so it is not a tab. It sits last, after the mail
   link, and carries a download arrow: the icon is what says the click ends in
   a file rather than a view, and it is why the rule under the current page can
   never land here. The arrow also does the work the accents would have done,
   which is why the word is set plainly: nothing sitting next to a download
   glyph reads as the verb. */
.site-nav__doc {
  display: inline-flex;
  align-items: center;
  gap: 5px;
}
.site-nav__doc svg {
  width: 14px;
  height: 14px;
  flex: none;
  /* The cap height of 16px Public Sans sits a shade above the box center, so
     the arrow is nudged down to read as level with the word rather than as
     floating off its shoulder. */
  transform: translateY(.5px);
}

/* The traveling rule under the current page. It marks one link and is the
   only horizontal line in the chrome. There is deliberately nothing else drawn
   on that line for it to sit against.

   One pixel wide, placed by `translateX` and stretched by `scaleX` from
   js/nav.js, so moving it costs no layout. It is `opacity: 0` until the script
   has measured, because an unplaced bar would sit under the first link and
   claim the wrong page; with no script it never appears and the current page
   is still marked by its ink color.

   Warm dark brown rather than the body's near-black: it is the display color,
   and the one mark in the chrome that says which page you are on should read
   as drawn rather than as a rendered underline. */
.site-nav__ink {
  position: absolute;
  left: 0;
  /* Flush with the header's padding edge. The distance to the writing is
     `--nav-gap` and nothing else; an offset here as well would be a second
     definition of the same gap, and the two drifted apart once already. */
  bottom: 0;
  /* A real width, set in script, rather than 1px stretched by `scaleX`. The
     scale was cheaper to animate, but the cross-document view transition
     snapshots this element and then interpolates that scale, which means it
     stretches a one-pixel picture of a bar across sixty pixels and back. The
     width is what should be moving, so the width is what animates.

     1px rather than 0 as the resting value, so a cached copy of the older
     script, which sized the bar with `scaleX` against exactly this pixel,
     still draws a bar rather than nothing at all. */
  width: 1px;
  /* 2px. It was 3 for as long as there was a hairline under the header taking
     the bottom pixel of that span; with the rule gone the whole 3 stood proud
     and the marker read as heavy, so it is back to the weight it had when it
     was the only thing on that line. */
  height: 2px;
  background: var(--ink-display);
  transform-origin: left center;
  opacity: 0;
  pointer-events: none;
  view-transition-name: nav-ink;
}

.site-nav__ink.is-placed { opacity: 1; }

.site-nav__ink.is-traveling {
  transition:
    transform var(--dur-travel) var(--ease),
    width var(--dur-travel) var(--ease);
}

/* Cross-document navigation. The root swaps outright and only the bar is
   animated, so the page does not cross-fade: the one thing that should read as
   continuous between two pages is the marker saying which page you are on.
   Browsers without cross-document view transitions ignore all of this and the
   bar simply appears in its new place. */
@view-transition { navigation: auto; }

::view-transition-old(root),
::view-transition-new(root) { animation: none; mix-blend-mode: normal; }

/* One bar, not two. The browser captures the marker on both pages and, left
   alone, cross-fades the two snapshots on top of the morph; turning the fades
   off without anything else left both of them fully drawn at once, which is
   worse, since two bars overlapping at the same y read as one much heavier
   bar. So the old snapshot is dropped outright and only the new one is drawn,
   carried from the old position to the new by the group's own morph. */
::view-transition-old(nav-ink) { animation: none; opacity: 0; }
::view-transition-new(nav-ink) { animation: none; opacity: 1; mix-blend-mode: normal; }

/* The easing as well as the duration. Left alone the group takes the browser's
   default curve, which starts and stops harder than anything else on the site
   moves. */
::view-transition-group(nav-ink) {
  animation-duration: var(--dur-travel);
  animation-timing-function: var(--ease);
}

@media (prefers-reduced-motion: reduce) {
  .site-nav a { transition: none; }
  .site-nav__ink.is-traveling { transition: none; }
  ::view-transition-group(nav-ink) { animation: none; }
}

.page-head { margin-bottom: var(--s7); }
/* The tag pill. Lives here rather than in projects.css because it is on two
   pages now: a toggle in the board's filter rows and a plain label in a
   write-up's rail. This block is only what the two share, the resting look.
   Everything that makes it pressable stays in projects.css. */
.pill {
  font-family: inherit;
  font-size: var(--text-label);
  line-height: 1.4;
  color: var(--muted);
  background: transparent;
  border: 1px solid var(--line);
  border-radius: var(--radius-full);
  padding: 5px var(--s3);
}

.page-head h1 {
  font-family: var(--serif);
  font-weight: 400;
  color: var(--ink-display);
  font-size: var(--text-display);
  line-height: 1.08;
  letter-spacing: -.015em;
  margin: 0;
}

/* Arrived here through the nav or the back button, or motion is not wanted:
   the mark is simply drawn in its finished state. The quotes need their opacity set explicitly here,
   because their resting value is 0 and it was the animation's `both` fill that
   brought them to 1. Without this they are stranded invisible, which is the
   entrance-never-fired bug in its purest form. */
:root.wm-seen .wordmark,
:root.wm-seen .wm__like,
:root.wm-seen .wm__tail,
:root.wm-seen .wm__q { animation: none; }
:root.wm-seen .wm__q { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .wordmark,
  .wm__like,
  .wm__tail,
  .wm__q { animation: none; }
  .wm__q { opacity: 1; }
}
/* The name line at the foot of every page. A `<p>`, not a heading, and
   deliberately: each page already carries one `h1` saying what that page is,
   and a second heading at the foot would compete with it to state a fact the
   page's JSON-LD already states in the form a crawler reads, a `Person` with
   `name` and `jobTitle`. A heading styled down to 14px is also a heading
   pretending not to be one, which is the mismatch a screen reader announces.

   No rule above it, for the same reason the header has none under it: space
   separates a band from the page. The gap is `--s8`, larger than any gap
   inside the page, so the line reads as belonging to the document rather than
   to the last thing above it.

   Centered, which is the one place on the site that is. The house rule against
   centered text is about prose: a reader returning to a ragged left edge line
   after line loses the start of each one. This is a single short line that
   closes the document and belongs to no column, so it has no left edge to
   agree with and nothing following it to find. */
.site-foot {
  /* `auto` does the pushing on a short page and collapses on a long one, so
     the padding rather than a margin carries the minimum gap: a margin here
     would be the thing `auto` replaces, and the line would end up against the
     content above it on every page that fills the screen. */
  margin-top: auto;
  padding-top: var(--s8);
  font-family: var(--sans);
  font-size: var(--text-label);
  line-height: 1.5;
  color: var(--muted);
  text-align: center;
}

.site-foot p { margin: 0; }

/* The separator. An em dash, chosen over the middle dot the write-up eyebrow
   uses and over a comma, and it is a deliberate exception: the house rules ban
   em dashes in copy, and this is the one place the site spends one. It is not
   copy. It is two labels on one line, and the dash is doing the job of a rule
   between them rather than the job of punctuation inside a sentence.

   `aria-hidden`, the same as `.note-eyebrow__sep`, so the line is announced as
   the two facts it carries and not as a dash between them. That is also why
   the spaces around it stay in the markup as real spaces rather than moving
   into this rule as padding: hide the dash and take the padding away, and a
   screen reader is left reading `nhi nguyensoftware engineer`. One space each
   side is the whole gap, and this rule adds nothing, because an em dash at
   14px is long enough already. */
.site-foot__sep { padding: 0; }

@media (max-width: 660px) {
  .wrap { padding: var(--s5) var(--s5) var(--s5); }
  .site-head { flex-wrap: wrap; }
}
