/* Opt every page into cross-document view transitions. Browsers that support
   it animate same-origin navigations between opted-in pages automatically —
   no JS needed; unsupported browsers just navigate normally. The new page
   wipes up into view rather than a plain cross-fade — it "stops and reveals"
   the destination, echoing the scroll-down gesture that got you there. A
   literal particle-dissolve would need canvas/WebGL; this is the CSS-only
   approximation. */
@view-transition {
  navigation: auto;
}

@keyframes reveal-old {
  to { opacity: 0.3; }
}

@keyframes reveal-new {
  from { clip-path: inset(100% 0 0 0); }
  to { clip-path: inset(0 0 0 0); }
}

::view-transition-old(root) {
  animation: reveal-old 0.45s var(--ease) forwards;
}

::view-transition-new(root) {
  animation: reveal-new 0.5s var(--ease) forwards;
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation: none;
  }
}

/* Shared design tokens — see DESIGN.md for the rationale behind each value. */
:root {
  --font-body: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-display: "Fraunces", Georgia, serif;

  --space-1: 0.5rem;
  --space-2: 1rem;
  --space-3: 1.5rem;
  --space-4: 2.5rem;
  --space-5: 4rem;

  --radius: 10px;
  --radius-lg: 16px;
  --max-width: 780px;

  --ease: cubic-bezier(0.2, 0.6, 0.2, 1);
  --speed: 0.18s;
}

/* Themed text selection — every theme's own --bg happens to be the right
   contrast partner for its own --accent (white bg on home/professional
   pairs with their deep-blue accent the same way .bored-trigger's white-
   on-accent text already does; personal's near-black bg pairs with its
   light-blue accent the same way), so one generic rule works correctly
   across all three themes with no per-theme override. Cascades down from
   whichever body.theme-* class the page has, same as every other custom
   property here — cheap, sitewide, and otherwise left to the browser
   default blue that belongs to no design system. */
::selection {
  background: var(--accent);
  color: var(--bg);
}

* {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  margin: 0;
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img,
svg {
  max-width: 100%;
  display: block;
}

.site-header .header-inner,
.lab-header-inner,
main#main,
.site-footer {
  max-width: var(--max-width);
  margin: 0 auto;
  padding-left: var(--space-3);
  padding-right: var(--space-3);
}

.site-header .header-inner,
.lab-header-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-2);
  padding-top: var(--space-1);
  padding-bottom: var(--space-1);
}

.site-header nav,
.lab-header nav {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
}

/* One floating liquid-glass pill nav, shared by every themed header
   (.site-header on /resume//life/, .lab-header on /lab//skills/) instead of
   each theme reimplementing it — ported from /life/'s own nav, which had
   this treatment first. Position/shape/blur are generic; the actual color
   comes from each theme's own --surface/--border custom properties
   (already defined per theme for other components), so no per-theme
   override is needed here.
   Pushed more transparent still (32%→16%→9%→4%→0%) plus a stronger blur/
   saturate each round, per repeated direct "more transparent, more like
   glass" feedback. The rotating ring tried in an earlier pass read as a
   loading-spinner, not glass — dropped per direct "circular animation is
   not looking good" feedback. The "this is glass, and it's alive" signal
   now comes from the top rim highlight itself slowly breathing (a plain
   opacity pulse via `@property --glass-shine`, not a second element) —
   just the edge brightening and dimming like light shifting across a
   lensed surface, nothing that reads as a spinner. Blur/saturate also
   eased back down twice now (42px/230%→20px/160%→8px/115%), per repeated
   "more transparent" feedback landing on "like clear glass" specifically
   — frosted glass blurs what's behind it into soft color; clear glass
   barely blurs at all, just a slight refraction, so blur had to come
   down close to a window-pane minimum rather than a frosted-shower-door
   one. Every edge/shadow opacity trimmed again to match. */
.site-header,
.lab-header {
  position: fixed;
  top: var(--space-2);
  left: 50%;
  transform: translateX(-50%);
  z-index: 90;
  width: calc(100% - var(--space-3) * 2);
  max-width: var(--max-width);
  border: 1px solid color-mix(in srgb, var(--border) 2%, transparent);
  border-radius: 999px;
  background: transparent;
  /* A plain, always-on ring — the resting edge definition every browser
     sees, animated or not. */
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, white 14%, transparent),
    0 8px 32px color-mix(in srgb, black 3%, transparent);
  backdrop-filter: blur(2px) saturate(105%);
  -webkit-backdrop-filter: blur(2px) saturate(105%);
  transition: transform 0.3s var(--ease);
}

/* The breathing pulse read as decoration with no reason behind it, per
   direct "not meaningful" feedback — real glass doesn't glow on a timer,
   it catches light differently depending on where you're looking from.
   This is the CSS-only stand-in for that: assets/js/nav-autohide.js
   tracks the pointer and writes its position into --glass-mx/--glass-my
   (same "cursor position drives a custom property" technique
   .spotlight-text already uses elsewhere on this site), and this ring —
   masked to just the border band, same two-layer content-box/exclude
   trick used sitewide for "paint only the edge" — reads a bright glint
   from a radial gradient centered there. Move the cursor near the nav
   and the glint slides along the rim toward it, like light actually
   grazing a glass edge; move away and it fades. Hidden outright for
   touch/no-hover visitors (nav-autohide.js never sets the properties,
   and `hover:hover` below covers anyone whose pointer can't move without
   also tapping) and under reduced motion, where a moving highlight would
   be exactly the kind of motion that preference is asking to skip. */
.site-header::before,
.lab-header::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  background: radial-gradient(
    120px 120px at var(--glass-mx, 50%) var(--glass-my, 0%),
    color-mix(in srgb, white 95%, transparent),
    transparent 70%
  );
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  opacity: 0;
  transition: opacity 0.25s ease;
  pointer-events: none;
}

@media (hover: hover) and (pointer: fine) {
  .site-header:hover::before,
  .lab-header:hover::before {
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .site-header::before,
  .lab-header::before {
    display: none;
  }
}

/* Hides on scroll-down, returns on scroll-up (assets/js/nav-autohide.js) —
   so the nav is always a short scroll-up away, never a trip back to the
   very top. The glass background means it never fully hides content
   either way, this is just extra screen real estate while scrolling. */
.site-header.is-nav-hidden,
.lab-header.is-nav-hidden {
  transform: translateX(-50%) translateY(-150%);
}

.brand {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.15rem;
  text-decoration: none;
  letter-spacing: -0.01em;
}

/* Legibility floor for text sitting directly on the glass pill (the brand
   mark, and any plain-text nav link with no button backing of its own —
   .offset-btn/.galaxy-button already paint their own solid/near-solid
   fill, so they don't need this). A blurred text-shadow halo here read as
   exactly what it was — a dark glow stamped behind the letters to force
   contrast — per direct "it looks bad" feedback. A thin, crisp stroke
   (no blur radius) reads as the glyph's own edge instead of an added
   effect, the same way subtitle text or an embossed label stays legible
   over a photo without needing a visible shadow: the stroke, in the
   page's own --bg, is what guarantees contrast against literally
   anything behind the clear glass. */
.brand,
.site-header nav a:not(.offset-btn):not(.galaxy-button),
.lab-header nav a {
  -webkit-text-stroke: 0.6px var(--bg);
  paint-order: stroke fill;
}

.site-header nav a,
a.switch-side {
  transition: color var(--speed) var(--ease), background-color var(--speed) var(--ease), border-color var(--speed) var(--ease);
}

/* Plain-text nav links have no visible button box, so padding is the only
   way to reach a comfortable tap target — grown vertically only (44px is
   about height; these were already wide enough) so the look stays "text
   link," not "button." align-items: center on .site-header nav (above)
   keeps the taller link vertically centered next to its siblings.
   :not(.galaxy-button) excludes the galaxy button, which is itself an <a>
   here but sizes its own tap target via .galaxy-btn-face's min-height —
   this padding would otherwise stack on top of that.
   :not(.offset-btn) added when merging page/life: /life/'s own nav uses
   .offset-btn (not .galaxy-button) for its cross-link, and this rule's
   `padding: 0.65rem 0` fully overrides all four sides — it was silently
   zeroing out .offset-btn's own horizontal padding (0.5rem 1.1rem),
   collapsing the button to ~44px wide with the label crammed against its
   dashed border. Confirmed via computed style before and after this fix,
   not just reasoned about: paddingLeft/Right read 0px beforehand. */
.site-header nav a:not(.galaxy-button):not(.offset-btn),
.lab-header nav a {
  padding: 0.65rem 0;
}

main#main {
  padding-top: var(--space-2);
  padding-bottom: var(--space-5);
}

.site-footer {
  padding-top: var(--space-3);
  padding-bottom: var(--space-4);
  font-size: 0.85rem;
}

h1, h2, h3 {
  font-family: var(--font-display);
  line-height: 1.2;
  letter-spacing: -0.01em;
}

p {
  max-width: 68ch;
}

@media (max-width: 480px) {
  :root {
    --space-5: 2.5rem;
  }
}

/* ---------------------------------------------------------------------
   Shared components — used on any page, not tied to one theme's CSS file.
   Each carries its own fixed color identity (see DESIGN.md): the offset
   button is "the professional button" wherever it appears (resume actions,
   the personal page's link back to the résumé); the keycap button is "the
   personal button" (the homepage's and résumé's links into /life/). Same
   component + same colors everywhere it's used, regardless of which
   page's background it sits on.
   --------------------------------------------------------------------- */

/* Offset button — dashed pill, lifts up-left on hover with a hard offset
   shadow. The button body matches its own page — background: var(--bg),
   text/dashed border: var(--text) — so it reads as native to whichever
   theme it's on (white body/black text/black dashes on the light pages,
   dark body/white text/white dashes on the personal page). Blue is the one
   constant: the hover shadow is always the same accent blue, in both
   themes, so that's the button's fixed "professional" signal regardless of
   background. Shape never changes (always a full pill) — only position and
   shadow animate, so hover reads as "lifting," not "turning into a
   different control." */
.offset-btn {
  --offset-shadow: #1d4ed8;

  display: inline-flex;
  align-items: center;
  gap: clamp(0.4rem, 1vw, 0.6rem);
  padding: clamp(0.6rem, 1.2vw + 0.3rem, 0.95rem) clamp(1.1rem, 2.5vw + 0.4rem, 1.85rem);
  border-radius: 999px;
  border: 2px dashed var(--text);
  background: var(--bg);
  /* !important: professional.css/personal.css set a blanket `a { color:
     var(--accent) }` for prose links, which outranks a bare `.offset-btn`
     class on specificity (class+type beats class alone) — without this the
     label silently renders in the wrong color. */
  color: var(--text) !important;
  font-weight: 600;
  font-size: clamp(0.9rem, 0.6vw + 0.75rem, 1.05rem);
  text-decoration: none;
  transition:
    transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1),
    box-shadow 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
  box-shadow: 0 0 0 rgba(0, 0, 0, 0);
}

.offset-btn .btn-arrow {
  transition: transform 0.25s ease;
}

.offset-btn:hover,
.offset-btn:focus-visible {
  transform: translate(-8px, -8px);
  box-shadow: 8px 8px 0 var(--offset-shadow);
}

.offset-btn:hover .btn-arrow,
.offset-btn:focus-visible .btn-arrow {
  transform: translateX(4px);
}

.offset-btn:active {
  transform: translate(0, 0);
  box-shadow: 0 0 0 rgba(0, 0, 0, 0);
  transition-duration: 0.08s;
}

.offset-btn:focus-visible {
  outline: 3px solid var(--offset-shadow);
  outline-offset: 5px;
}

@media (prefers-reduced-motion: reduce) {
  .offset-btn,
  .offset-btn .btn-arrow {
    transition-duration: 0.01ms;
  }

  .offset-btn:hover,
  .offset-btn:focus-visible {
    transform: none;
  }
}

/* A nav bar is a tighter space than a hero — same component, smaller. */
.site-header .offset-btn {
  padding: 0.5rem 1.1rem;
  font-size: 0.9rem;
  gap: 0.4rem;
}

/* Keycap button — a rectangular key with a solid offset "lip" standing in
   for its side wall (classic flat-3D keycap trick, no perspective/rotation:
   an earlier tilted version was hard to read). Hover only glows, never
   moves; the lip collapses and the key drops into it on :active, like an
   actual keypress. Sized to match plain nav-link text so it doesn't dominate
   next to "Resume"/"Life & Instagram". */
.keycap-btn {
  --keycap-fill: #12181f;
  --keycap-edge: #080b0f;
  --keycap-glow: #6ec1ff;
  --keycap-text: #cfe6ff;
  --keycap-text-hover: #ffffff;

  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 1.1rem;
  border-radius: 8px;
  background: var(--keycap-fill);
  color: var(--keycap-text) !important;
  font-weight: 600;
  font-size: 0.95rem;
  text-decoration: none;
  box-shadow:
    0 4px 0 var(--keycap-edge),
    0 6px 12px rgba(0, 0, 0, 0.35);
  transition: box-shadow 0.2s var(--ease), color 0.2s ease;
}

.keycap-btn:hover,
.keycap-btn:focus-visible {
  color: var(--keycap-text-hover) !important;
  box-shadow:
    0 4px 0 var(--keycap-edge),
    0 6px 12px rgba(0, 0, 0, 0.35),
    0 0 18px 3px var(--keycap-glow);
}

.keycap-btn:active {
  transform: translateY(4px);
  box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
  transition-duration: 0.05s;
}

.keycap-btn:focus-visible {
  outline: 2px solid var(--keycap-glow);
  outline-offset: 3px;
}

/* Contact button + modal — shared across every page via footer.html.
   Unlike .offset-btn/.keycap-btn this has no fixed brand identity: it
   reads entirely off the current page's own theme tokens, so it looks
   native on light and dark pages alike instead of importing one page's
   palette into another's. */
.contact-btn {
  display: inline-flex;
  align-items: center;
  margin-left: 0.4em;
  padding: 0.2em 0.7em;
  border: 1px solid var(--border);
  border-radius: 999px;
  font: inherit;
  font-size: 0.85em;
  line-height: inherit;
  /* !important: every theme sets a blanket `a { color: var(--accent) }`
     for prose links (professional.css/personal.css/lab.css), which outranks
     a bare .contact-btn class on specificity — same trap .offset-btn's own
     comment documents. Coincidentally harmless on the personal/professional
     footers (their own .site-footer a rule already matches --muted), but a
     real, silent wrong-color bug on /lab/, which has no such footer rule to
     accidentally save it. */
  color: var(--muted) !important;
  text-decoration: none;
  background: none;
  cursor: pointer;
  transition: color var(--speed) var(--ease), border-color var(--speed) var(--ease);
}

.contact-btn:hover,
.contact-btn:focus-visible {
  color: var(--text) !important;
  border-color: var(--text);
}

.contact-modal {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-3);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity var(--speed) var(--ease), visibility 0s var(--speed);
}

.contact-modal.is-open {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transition: opacity var(--speed) var(--ease);
}

/* Deliberately heavier than the floating nav's own blur (14px) — a modal
   sitting on top of the whole page reads as "glass" more convincingly with
   more separation from what's behind it than a thin nav bar needs. */
.contact-modal-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(5, 7, 12, 0.6);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

/* Genuinely see-through, not just "a slightly different dark box" — the
   first version (80% opaque --surface) read as solid on this page's own
   mostly-dark backgrounds, where there's little tonal difference to reveal
   a blend. 45% plus a heavier blur actually shows the page moving behind
   it. color-mix keeps this correct against every theme's own --surface
   without a separate rgba() shade hand-tuned per page. The accent-tinted
   glow (not a flat --border line) is the same "cosmic" language as the
   Black Hole background this sits on top of, rather than a generic form
   dropped onto the page. */
.contact-modal-panel {
  position: relative;
  width: min(520px, 100%);
  max-height: 100%;
  overflow-y: auto;
  background: color-mix(in srgb, var(--surface) 45%, transparent);
  backdrop-filter: blur(28px) saturate(150%);
  -webkit-backdrop-filter: blur(28px) saturate(150%);
  border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border));
  border-radius: var(--radius-lg);
  padding: var(--space-4);
  color: var(--text);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.08),
    0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent),
    0 20px 60px -12px color-mix(in srgb, var(--accent) 30%, transparent),
    0 30px 80px -20px rgba(0, 0, 0, 0.65);
  transform: translateY(16px) scale(0.96);
  transition: transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.contact-modal.is-open .contact-modal-panel {
  transform: translateY(0) scale(1);
}

.contact-modal-close {
  position: absolute;
  top: var(--space-1);
  right: var(--space-1);
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border: none;
  border-radius: 50%;
  background: none;
  color: var(--muted);
  cursor: pointer;
  transition: color var(--speed) var(--ease), background-color var(--speed) var(--ease);
}

.contact-modal-close:hover,
.contact-modal-close:focus-visible {
  color: var(--text);
  background: color-mix(in srgb, var(--accent) 15%, transparent);
}

.contact-modal-title {
  display: flex;
  align-items: center;
  gap: 0.5em;
  margin: 0 0 var(--space-1);
  padding-right: 3.5rem;
  font-size: 1.3rem;
}

.contact-modal-title svg {
  flex-shrink: 0;
  color: var(--accent);
}

.contact-modal-sub {
  margin: 0 0 var(--space-3);
  color: var(--muted);
  font-size: 0.9rem;
}

.contact-field {
  display: block;
  margin-bottom: var(--space-2);
  font-size: 0.85rem;
}

.contact-field span {
  display: block;
  margin-bottom: 0.35em;
  color: var(--muted);
}

.contact-field input,
.contact-field select,
.contact-field textarea {
  width: 100%;
  padding: 0.6em 0.75em;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--bg) 40%, transparent);
  color: var(--text);
  font: inherit;
  font-size: 0.95rem;
  resize: vertical;
  transition: border-color var(--speed) var(--ease), box-shadow var(--speed) var(--ease);
}

.contact-field input {
  min-height: 44px;
}

.contact-field input:focus-visible,
.contact-field select:focus-visible,
.contact-field textarea:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}

.contact-submit {
  padding: 0.7em 1.5em;
  border: none;
  border-radius: 999px;
  background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 70%, white));
  color: #fff;
  font: inherit;
  font-weight: 600;
  font-size: 0.95rem;
  cursor: pointer;
  box-shadow: 0 4px 16px -4px color-mix(in srgb, var(--accent) 60%, transparent);
  transition: transform var(--speed) var(--ease), box-shadow var(--speed) var(--ease), opacity var(--speed) var(--ease);
}

.contact-submit:hover,
.contact-submit:focus-visible {
  transform: translateY(-1px);
  box-shadow: 0 8px 24px -4px color-mix(in srgb, var(--accent) 75%, transparent);
}

.contact-submit:active {
  transform: translateY(0);
}

.contact-submit:disabled {
  opacity: 0.6;
  cursor: default;
  transform: none;
}

.contact-topics {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 0 0 var(--space-2);
  padding: 0;
  border: 0;
}

.contact-topics label {
  cursor: pointer;
}

.contact-topics input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.contact-topics span {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 0.9em;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: color-mix(in srgb, var(--bg) 40%, transparent);
  font-size: 0.85rem;
  transition: transform var(--speed) var(--ease), border-color var(--speed) var(--ease), background var(--speed) var(--ease);
}

.contact-topics label:hover span {
  transform: translateY(-2px);
  border-color: var(--accent);
}

.contact-topics input:checked + span {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 22%, transparent);
  font-weight: 600;
}

.contact-topics input:focus-visible + span {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent);
}

.contact-starters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin: 0 0 var(--space-2);
}

.contact-starters[hidden] {
  display: none;
}

.contact-starters button {
  min-height: 44px;
  padding: 0 0.8em;
  border: 1px dashed var(--accent);
  border-radius: 999px;
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: 0.8rem;
  cursor: pointer;
  transition: background var(--speed) var(--ease);
}

.contact-starters button:hover,
.contact-starters button:focus-visible {
  background: color-mix(in srgb, var(--accent) 18%, transparent);
}

.contact-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0 0.6rem;
}

@media (max-width: 520px) {
  .contact-row {
    grid-template-columns: 1fr;
  }
}

.contact-note {
  margin: calc(var(--space-1) * -1) 0 var(--space-2);
  font-size: 0.75rem;
  color: var(--muted);
}

.contact-note a {
  color: var(--accent);
}

.contact-error {
  margin: 0 0 var(--space-2);
  font-size: 0.8rem;
  color: #e0575b;
}

.contact-error[hidden] {
  display: none;
}

.contact-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}

.contact-count {
  font-size: 0.75rem;
  color: var(--muted);
}

.contact-done {
  text-align: center;
  padding: var(--space-4) 0;
  color: var(--accent);
}

.contact-done[hidden] {
  display: none;
}

.contact-done h3 {
  margin: var(--space-2) 0 0.25rem;
  color: var(--text);
}

.contact-done p {
  margin: 0;
  color: var(--muted);
}

.contact-done circle {
  stroke-dasharray: 164;
  stroke-dashoffset: 164;
  animation: contact-draw 0.6s var(--ease) forwards;
}

.contact-done path {
  stroke-dasharray: 40;
  stroke-dashoffset: 40;
  animation: contact-draw 0.4s 0.4s var(--ease) forwards;
}

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

.contact-status {
  margin: var(--space-2) 0 0;
  font-size: 0.85rem;
  color: var(--muted);
  min-height: 1.2em;
}

.contact-status[data-state="error"] {
  color: #e0575b;
}

@media (prefers-reduced-motion: reduce) {
  .contact-done circle,
  .contact-done path {
    animation: none;
    stroke-dashoffset: 0;
  }
  .contact-modal,
  .contact-modal.is-open,
  .contact-modal-panel {
    transition-duration: 0.01ms;
  }
}

/* Slice Blade trigger + modal — shared across every page via
   footer.html (plus its own homepage-specific trigger in home.css,
   since the homepage has no footer at all by deliberate design). Per
   explicit request the trigger has to actually be noticed, not just
   technically present, so unlike .contact-btn's quiet inline-text
   treatment this is a solid, standalone pill with a small idle wiggle
   on its icon — still built from this component set's own established
   pieces (the accent gradient/lift already used by .contact-submit),
   not an unrelated new visual language. Modal shell mirrors
   .contact-modal's own structure and transitions exactly; only the
   panel's own sizing differs, since a game needs real canvas room
   instead of a form's fixed narrow width. */
.bored-trigger {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  margin-left: 0.6em;
  padding: 0.45em 0.9em;
  border: none;
  border-radius: 999px;
  background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 70%, white));
  color: #fff !important;
  font: inherit;
  font-size: 0.85em;
  font-weight: 600;
  line-height: 1;
  text-decoration: none;
  cursor: pointer;
  box-shadow: 0 4px 14px -4px color-mix(in srgb, var(--accent) 60%, transparent);
  transition: transform var(--speed) var(--ease), box-shadow var(--speed) var(--ease);
}

.bored-trigger:hover,
.bored-trigger:focus-visible {
  transform: translateY(-1px);
  box-shadow: 0 8px 20px -4px color-mix(in srgb, var(--accent) 75%, transparent);
}

.bored-trigger-icon {
  display: inline-flex;
  animation: bored-trigger-wiggle 2.6s ease-in-out infinite;
}

@keyframes bored-trigger-wiggle {
  0%, 100% { transform: rotate(0deg); }
  50% { transform: rotate(-16deg); }
}

@media (prefers-reduced-motion: reduce) {
  .bored-trigger-icon {
    animation: none;
  }
}

/* The game is the product, so the play area gets everything the chrome
   doesn't need. Desktop: a large near-full-window stage. Phones and
   short landscape windows: genuinely full-screen (no margin, no radius,
   no border), since every pixel of padding there is pixels of game lost.
   `100%` heights resolve against .contact-modal's fixed inset:0 box,
   which tracks the dynamic viewport (browser toolbars) on mobile, unlike
   100vh. overscroll-behavior stops a downward swipe from turning into
   pull-to-refresh behind the game. */
.slice-blade-modal {
  padding: var(--space-2);
  overscroll-behavior: contain;
}

.slice-blade-panel {
  width: min(1200px, 100%);
  height: min(100%, 860px);
  max-height: none;
  padding: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  /* The canvas paints an opaque --bg over the whole stage, so the shared
     panel's 28px backdrop blur has nothing to show through and only
     costs GPU on a phone running a 60fps canvas. */
  background: var(--bg);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

@media (max-width: 640px), (max-height: 520px) {
  .slice-blade-modal {
    padding: 0;
  }

  .slice-blade-panel {
    width: 100%;
    height: 100%;
    border: 0;
    border-radius: 0;
    box-shadow: none;
  }
}

/* One slim bar: title on the left, score and lives on the right, with
   room reserved for the 44px close button. Replaces a title row, a
   4-5 line rules paragraph and a floating HUD that together cost about
   as much height as the game itself did on a phone. */
.slice-blade-header {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  min-height: 52px;
  padding: 0 56px 0 var(--space-2);
  flex-shrink: 0;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
}

.slice-blade-close {
  top: 4px;
  right: 4px;
}

.slice-blade-title {
  display: flex;
  align-items: center;
  gap: 0.5em;
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  font-size: 1rem;
}

.slice-blade-title svg {
  flex-shrink: 0;
  color: var(--accent);
}

.slice-blade-title-text {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* On the narrowest phones the chips need the room more than the word
   does; the icon stays and the heading remains the dialog's accessible
   name (visually hidden, not display:none). */
@media (max-width: 340px) {
  .slice-blade-title-text {
    position: absolute;
    width: 1px;
    height: 1px;
    clip: rect(0, 0, 0, 0);
  }
}

.slice-blade-hud {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex-shrink: 0;
}

.slice-blade-hud-chip {
  display: inline-flex;
  padding: 0.3em 0.7em;
  border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 10%, var(--bg));
  border: 1px solid var(--border);
  color: var(--text);
  font-size: 0.85rem;
  font-weight: 600;
  line-height: 1.4;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

.slice-blade-hud-lives {
  color: var(--accent);
  letter-spacing: 0.15em;
}

/* The stage. A definite flex-filled box with the canvas absolutely
   filling it: the canvas used to be height:100% of an auto-height
   parent, which a replaced element resolves from its own intrinsic
   ratio instead, so it stayed a 2:1 strip (245x123 on a 375x812 phone)
   inside a taller box, and the strip below it was visible but never
   received pointer input. */
.slice-blade-host {
  position: relative;
  width: 100%;
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
  background: var(--bg);
  touch-action: none;
}

.slice-blade-host canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  cursor: crosshair;
}

/* Rules and challenge banner, floated over the empty top of the stage
   (pieces peak at about a quarter of the way down, never above it).
   pointer-events:none so they can never block a slice. */
.slice-blade-notes {
  position: absolute;
  top: var(--space-2);
  left: var(--space-2);
  right: var(--space-2);
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.4rem;
  pointer-events: none;
}

.slice-blade-hint,
.slice-blade-challenge {
  margin: 0;
  max-width: 36rem;
  text-wrap: balance;
  padding: 0.4em 0.9em;
  border: 1px solid var(--border);
  border-radius: 1.2rem;
  background: color-mix(in srgb, var(--surface) 82%, transparent);
  color: var(--text);
  font-size: 0.85rem;
  line-height: 1.4;
  text-align: center;
}

.slice-blade-challenge {
  border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
  font-weight: 600;
}

/* visibility is delayed past the fade so a dismissed hint is also gone
   from the accessibility tree, not just transparent. */
.slice-blade-hint {
  transition: opacity 0.3s var(--ease), visibility 0s;
}

.slice-blade-hint.is-dismissed {
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.3s var(--ease), visibility 0s 0.3s;
}

/* Game-over share panel — overlaid on the bottom of .slice-blade-host
   (same absolute-inside-the-relative-host technique .slice-blade-notes
   already uses), not a block below it, so it adds zero height to the
   modal panel: whatever already fit on screen without scrolling still
   fits, on a phone, a tablet, or a desktop window. Reuses
   .contact-submit's gradient pill for the one "best" option (native
   share sheet) and a plain bordered pill, sized to this project's own
   established >=44px touch-target floor, for everything the Web Share
   API doesn't cover on its own. */
.slice-blade-share {
  position: absolute;
  left: var(--space-1);
  right: var(--space-1);
  bottom: max(var(--space-1), env(safe-area-inset-bottom));
  max-width: 760px;
  margin-inline: auto;
  z-index: 2;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.6rem;
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--surface) 75%, transparent);
  backdrop-filter: blur(10px) saturate(150%);
  -webkit-backdrop-filter: blur(10px) saturate(150%);
  border: 1px solid var(--border);
}

/* [hidden]: a bare class selector's `display: flex` above is author-
   origin CSS, which beats the UA stylesheet's `[hidden] { display: none }`
   outright regardless of specificity — the exact bug this project has
   already hit twice (.lab-modal, .ig-feed-loading). Without this, the
   JS below was correctly toggling the hidden attribute the whole time,
   it just never had any visual effect: the panel stayed on screen
   during live play, not just at game-over. */
.slice-blade-share[hidden] {
  display: none;
}

.slice-blade-share-score {
  margin: 0;
  font-size: 0.85rem;
  color: var(--muted);
}

.slice-blade-share-score strong {
  color: var(--text);
}

.slice-blade-share-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.slice-blade-share-btn {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0.4em 0.9em;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: color-mix(in srgb, var(--bg) 70%, transparent);
  color: var(--text);
  font: inherit;
  font-size: 0.8rem;
  font-weight: 600;
  cursor: pointer;
  touch-action: manipulation;
  transition: border-color var(--speed) var(--ease), transform var(--speed) var(--ease);
}

.slice-blade-share-btn:hover,
.slice-blade-share-btn:focus-visible {
  border-color: var(--accent);
  transform: translateY(-1px);
}

.slice-blade-share-btn-primary {
  border: none;
  background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 70%, white));
  color: #fff;
  box-shadow: 0 4px 16px -4px color-mix(in srgb, var(--accent) 60%, transparent);
}

.slice-blade-share-note {
  margin: 0;
  font-size: 0.75rem;
  color: var(--muted);
  min-height: 1.1em;
}

/* Visually-hidden but still readable by screen readers — a real <label>
   that doesn't need to take up layout space (e.g. a search input whose
   placeholder already carries the visible hint). */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Cursor trail (assets/js/cursor-trail.js) — a full-viewport overlay, so it
   has to sit above literally everything (every modal on this site tops
   out at z-index:300) to read as attached to the real cursor rather than
   tucked behind some page's own stacking context. pointer-events:none
   means the stacking order can never actually block a click regardless;
   this is a belt-and-suspenders value, not a load-bearing one.
   width/height:100% is load-bearing, not decorative: a <canvas> is a
   replaced element, so inset:0 alone does NOT stretch it to fill the
   fixed-position box the way it would a <div> — without an explicit
   size, the browser renders it at its own width/height ATTRIBUTE value
   (the DPR-scaled backing-buffer size cursor-trail.js sets in JS, e.g.
   2048×1536 on a 1024×768/2x display) as real CSS pixels, not scaled
   down to the viewport. Reported directly as "trail is looking very
   big... big offset between pointer and trail" — exactly what drawing
   in a coordinate space sized for 1024×768 looks like once the browser
   displays that same canvas at roughly double its real width and
   height in every direction from the fixed top-left origin. */
.cursor-trail-canvas {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 9999;
  pointer-events: none;
}

/* Touch-target floor (44px, WCAG 2.5.5/Apple HIG) for the small standalone
   controls. Keyed on pointer type, not width, so tablets get it too and
   mouse desktops keep the compact look. Inline links inside sentences are
   exempt and left alone. */
@media (pointer: coarse) {
  .brand {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    min-width: 44px;
  }

  .contact-btn,
  .bored-trigger {
    min-height: 44px;
    justify-content: center;
  }

  .site-footer .footer-dev-note a,
  .ig-feed-links a,
  .video-row-label {
    display: inline-block;
    padding-block: 0.8rem;
  }

  .keycap-btn,
  summary {
    min-height: 44px;
  }
}
