/* ══════════════════════════════════════════════════════════════════════
   components.css — discrete UI pieces
   ══════════════════════════════════════════════════════════════════════ */

/* ══════════════════════════════════════════════════════════════════════
   1. CARD
   ══════════════════════════════════════════════════════════════════════ */
.card {
  background: var(--bg-1);
  border: 1px solid var(--rule);
  transition: border-color 250ms ease, transform 250ms ease, background var(--t);
}

.card:hover {
  border-color: var(--rule-2);
  transform: translateY(-2px);
}

.card-accent {
  border-top: 1px solid var(--accent-2);
  transition: border-color var(--t);
}


/* ══════════════════════════════════════════════════════════════════════
   2. TAGS
   ══════════════════════════════════════════════════════════════════════ */
.tag {
  display: inline-block;
  font-size: var(--fs-xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-3);
  border: 1px solid var(--rule);
  padding: 2px 8px;
  transition: color 200ms, border-color 200ms;
}

.tag:hover {
  color: var(--accent-3);
  border-color: var(--accent-3);
}

.tag-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.stack-tags {
  margin-top: var(--sp-7);
}


/* ══════════════════════════════════════════════════════════════════════
   3. TYPEWRITER CURSOR
   ══════════════════════════════════════════════════════════════════════ */
.tw-cursor {
  display: inline-block;
  width: 1px;
  height: 1em;
  background: var(--accent);
  margin-left: 1px;
  vertical-align: text-bottom;
  animation: cursorBlink 1.1s step-end infinite;
  transition: background var(--t);
}


/* ══════════════════════════════════════════════════════════════════════
   4. SPOTIFY — NOW PLAYING
   ══════════════════════════════════════════════════════════════════════ */
.spotify-widget {
  background: var(--bg-2);
  border: 1px solid var(--rule);
  padding: 1.25rem;
  transition: background var(--t), border-color var(--t);
}

/* Inside .hero-panel the outer border already exists, so the widget
   only needs the divider below it. */
.spotify-widget--flush {
  border: none;
  border-bottom: 1px solid var(--rule);
}

.np-label { margin-bottom: var(--sp-4); }

.np-body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
}

.np-row {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
}

.np-meta {
  flex: 1;
  min-width: 0;
}

.np-title,
.np-artist {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.np-title {
  color: var(--text);
  font-weight: 500;
  margin-bottom: 3px;
}

.np-artist {
  color: var(--text-3);
  margin-bottom: var(--sp-3);
}

.np-empty {
  color: var(--text-3);
  line-height: 1.7;
}

/* Album artwork thumbnail */
.album-art {
  width: 52px;
  height: 52px;
  flex-shrink: 0;
  object-fit: cover;
  box-shadow: 0 0 0 1px var(--rule);
  transition: box-shadow var(--t);
}

/* ── Equaliser ────────────────────────────────────────────────────── */
.eq-bars {
  display: flex;
  align-items: flex-end;
  gap: 2px;
  height: 14px;
}

/* Bars animate at --bpm-half with staggered delays so they don't
   move in unison. */
.eq-bar {
  width: 2px;
  border-radius: 1px;
  background: var(--accent);
  transform-origin: bottom;
  animation: eqBounce var(--bpm-half) ease-in-out infinite alternate;
  transition: background var(--t);
}

/* Delays are fractions of the beat rather than fixed ms, so the stagger
   tightens and loosens with the tempo like the bars themselves. */
.eq-bar:nth-child(1) { height: 6px;  animation-delay: 0ms;                        }
.eq-bar:nth-child(2) { height: 10px; animation-delay: calc(var(--bpm-half) * 0.27); }
.eq-bar:nth-child(3) { height: 14px; animation-delay: calc(var(--bpm-half) * 0.13); }
.eq-bar:nth-child(4) { height: 8px;  animation-delay: calc(var(--bpm-half) * 0.40); }
.eq-bar:nth-child(5) { height: 12px; animation-delay: calc(var(--bpm-half) * 0.20); }

@keyframes eqBounce {
  from { transform: scaleY(0.25); }
  to   { transform: scaleY(1);    }
}

.eq-bar.paused {
  animation: none;
  transform: scaleY(0.25);
}

/* ── Audio features row ───────────────────────────────────────────── */
.af-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1px;
  background: var(--rule);
  border: 1px solid var(--rule);
  transition: background var(--t), border-color var(--t);
}

.af-cell {
  background: var(--bg-2);
  padding: var(--sp-3);
  transition: background var(--t);
}

.af-cell .label { margin-bottom: 3px; }

.af-value {
  font-family: 'Fraunces', Georgia, serif;
  font-weight: 300;
  color: var(--text);
  letter-spacing: -0.02em;
}

/* ══════════════════════════════════════════════════════════════════════
   5. HERO ATMOSPHERE
   ──────────────────────────────────────────────────────────────────────
   Three oversized blobs drifting on long, coprime cycles, inside a layer
   that swells once per beat. Both loops are passive and infinite, and
   neither is gated on playback; the track only ever changes their timing
   and amplitude, never starts or stops them. Everything the track controls
   arrives as a custom property written by theme.js:

     --grad-1..3    the stop colours, from the album artwork
     --drift        one musical phrase, from the tempo
     --bpm          one beat, from the tempo (set in tokens.css too)
     --drift-amp    how far the blobs travel, from the energy
     --pulse-depth  how much opacity the beat takes away, from the energy
     --pulse-scale  how far the layer swells on the beat, from the energy

   Only opacity and transform animate, so the whole layer stays on the
   compositor. Animating the colour stops every frame would instead
   repaint a viewport-sized gradient; they cross-fade once per track.
   ══════════════════════════════════════════════════════════════════════ */
.hero-bg {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;

  /* The ceiling on how far album colour may lift the local background.
     A token rather than a literal because the beat swell and the
     prefers-contrast override both need to read it.

     This was 0.24, derived from the --text-2 budget as though this layer
     sat directly behind the copy. It does not: .hero::before paints --bg
     back over the top at 0.55 alpha at the very centre, where the text is
     and where that scrim is at its thinnest. Composing the two properly —
     brightest album colour colour.js can emit, two blobs overlapping,
     scrim at its thinnest — the numbers are:

         layer 0.24 → rgb(22,22,24), --text-2 at 6.67:1
         layer 0.45 → rgb(28,28,30), --text-2 at 6.28:1
         layer 1.00 → rgb(45,45,46), --text-2 at 5.11:1

     So the old ceiling was roughly four times more conservative than the
     text needed, and the cost was a hero nobody could see move: 0.24 left
     about 2% effective alpha once the scrim had taken its cut.

     0.45 keeps a comfortable margin over the 4.5:1 AA floor. Re-run
     scratchpad/contrast.mjs before changing it, and note the binding
     constraint is the scrim's centre alpha in layout.css, not this value
     alone — weakening that scrim spends the same budget. */
  --hero-bg-max: 0.45;
  opacity: var(--hero-bg-max);

  /* Passive, continuous and unconditional: the beat is the layer's idle
     state, not something playback gates. --bpm has a sensible default in
     tokens.css, so this is already running before Spotify answers and
     simply retimes when a real tempo arrives. Nothing pauses it — a
     stopped or unreachable track just leaves it on the last known tempo. */
  animation: heroPulse var(--bpm) ease-out infinite;
}

/* Anyone asking for more contrast gets much less album colour lifting the
   hero background. Overriding the token rather than opacity keeps the beat
   pulse correct, since the keyframes are expressed against it.

   This lives here, not in base.css with the other preference queries: a
   media query adds no specificity, and base.css loads first, so from there
   this rule lost to the plain .hero-bg declaration above and never took
   effect at all. */
@media (prefers-contrast: more) {
  .hero-bg { --hero-bg-max: 0.15; }
}

/* One swell per beat, running continuously for as long as a tempo is
   known — this is the layer's resting state, not a transition. It restarts
   every --bpm regardless of whether the track has changed.

   Sharp attack, long decay: the shape of a note sounding rather than a
   light fading up and down, which is what makes it read as rhythm rather
   than as a slow glow.

   The attack is 20% of the beat, not the 8% it started at. Percentages of
   a beat get very short at the top of the tempo range this actually sees:
   at 174 BPM the beat is 345ms, so 8% was 27ms — under two frames at 60Hz,
   which renders as a flicker or as nothing rather than as a swell. 20%
   gives about 69ms there, and still reads as a sharp attack at 60 BPM.

   Scale is what carries the beat. Opacity alone cannot: --hero-bg-max caps
   the layer at 0.24, so the deepest available dip is 0.24 → 0.18, which on
   a gradient this soft is close to invisible. Scale sits outside the
   contrast budget — it moves the gradient without brightening it — so it
   can be felt while the alpha ceilings stay exactly where they were.

   The opacity half still only ever subtracts from --hero-bg-max, so the
   brightest the hero can get is precisely the static value above.

   Scaling here compounds with the blobs' own drift scale, which is the
   intent: the field breathes on the beat while the shapes inside it move
   on the phrase. .hero has overflow: hidden, so the swell clips. */
@keyframes heroPulse {
  0% {
    opacity: calc(var(--hero-bg-max) * (1 - var(--pulse-depth)));
    transform: scale(1);
  }
  20% {
    opacity: var(--hero-bg-max);
    transform: scale(calc(1 + var(--pulse-scale)));
  }
  100% {
    opacity: calc(var(--hero-bg-max) * (1 - var(--pulse-depth)));
    transform: scale(1);
  }
}

.hero-blob {
  position: absolute;
  width: 90%;
  aspect-ratio: 1;
  border-radius: 50%;
  /* No filter: blur(). A blur on an animating full-viewport layer forces
     re-rasterisation and buys nothing a soft gradient stop does not. */
  animation: heroDrift var(--drift) ease-in-out infinite alternate;
}

/* The per-blob multipliers below are 1 : 45/32 : 53/32. Those beat counts
   are pairwise coprime, so the three orbits take an impractically long
   time to realign however fast the track is and the loop never visibly
   repeats. Keep them coprime if you change them. */

.hero-blob--1 {
  top: -18%; left: -12%;
  background: radial-gradient(circle at 50% 50%, var(--grad-1) 0%, transparent 68%);
}

.hero-blob--2 {
  top: -6%; right: -16%;
  background: radial-gradient(circle at 50% 50%, var(--grad-2) 0%, transparent 66%);
  animation-duration: calc(var(--drift) * 1.40625);
  animation-direction: alternate-reverse;
}

.hero-blob--3 {
  bottom: -28%; left: 22%;
  background: radial-gradient(circle at 50% 50%, var(--grad-3) 0%, transparent 70%);
  animation-duration: calc(var(--drift) * 1.65625);
  /* Negative delay starts it part-way round, so the three do not all
     leave their origin together on load. Proportional to --drift rather
     than the fixed -12s this was, or a fast track would start it near
     the end of its cycle instead of a quarter of the way in. */
  animation-delay: calc(var(--drift) * -0.28);
}

/* --drift-amp scales travel and swell together, so a mellow track barely
   moves and an energetic one sweeps. It tops out at 1.15, which puts the
   peak radius within about 2% of the 1.12 this used to be fixed at —
   amplitude moves the blobs, it does not brighten them, so the overlap
   regions stay effectively as measured. */
@keyframes heroDrift {
  from {
    transform: translate3d(0, 0, 0) scale(1);
  }
  50% {
    transform: translate3d(calc(6% * var(--drift-amp)), calc(-4% * var(--drift-amp)), 0)
               scale(calc(1 + 0.12 * var(--drift-amp)));
  }
  to {
    transform: translate3d(calc(-5% * var(--drift-amp)), calc(5% * var(--drift-amp)), 0)
               scale(calc(1 - 0.06 * var(--drift-amp)));
  }
}

/* Deliberately no will-change: a running transform animation promotes the
   layer for its duration anyway, and declaring it would pin three
   viewport-sized textures in GPU memory permanently. */


/* ══════════════════════════════════════════════════════════════════════
   6. NAV NOW-PLAYING + DISCLOSURE TOGGLE
   ══════════════════════════════════════════════════════════════════════ */

/* Reserves its slot before it is revealed, so sliding in never reflows
   the bar. */
.nav-np {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  width: clamp(9rem, 18vw, 14rem);
  opacity: 0;
  transform: translateY(-140%);
  pointer-events: none;
  transition: opacity 260ms ease, transform 260ms cubic-bezier(.2, .8, .2, 1);
}

.nav-np.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* No crossorigin here: only the hero image feeds the colour canvas, and a
   second CORS request would just be a cache miss. */
.nav-np-art {
  width: 28px;
  height: 28px;
  flex-shrink: 0;
  object-fit: cover;
  box-shadow: 0 0 0 1px var(--rule);
}

.nav-np-meta {
  display: flex;
  flex-direction: column;
  min-width: 0;
  line-height: 1.25;
}

.nav-np-title,
.nav-np-artist {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.nav-np-title  { font-size: var(--fs-xs); color: var(--text); }
.nav-np-artist { font-size: 0.75rem; color: var(--text-3); }

.nav-np-eq { height: 10px; flex-shrink: 0; }

/* ── Disclosure toggle ────────────────────────────────────────────── */
.nav-toggle {
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  background: transparent;
  border: 1px solid var(--rule-2);
  color: var(--text-2);
  cursor: pointer;
  transition: border-color 200ms ease, color 200ms ease;
}

.nav-toggle:hover { border-color: var(--accent); color: var(--text); }

.nav-toggle-bars {
  display: flex;
  flex-direction: column;
  gap: 3px;
  width: 16px;
}

.nav-toggle-bars i {
  display: block;
  height: 1px;
  background: currentColor;
  transition: transform 200ms ease, opacity 200ms ease;
}

.nav-toggle[aria-expanded="true"] .nav-toggle-bars i:nth-child(1) { transform: translateY(4px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] .nav-toggle-bars i:nth-child(2) { opacity: 0; }
.nav-toggle[aria-expanded="true"] .nav-toggle-bars i:nth-child(3) { transform: translateY(-4px) rotate(-45deg); }


/* ══════════════════════════════════════════════════════════════════════
   7. HERO STATS
   ══════════════════════════════════════════════════════════════════════ */
.hero-stats {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1px;
  background: var(--rule);
  transition: background var(--t);
}

.hero-stat {
  background: var(--bg-1);
  padding: var(--sp-5);
  transition: background var(--t);
}

.hero-stat-value {
  font-family: 'Fraunces', Georgia, serif;
  font-size: var(--fs-xl);
  font-weight: 300;
  color: var(--text);
  letter-spacing: -0.03em;
  line-height: 1;
  margin-bottom: 4px;
  transition: color var(--t);
}


/* ══════════════════════════════════════════════════════════════════════
   8. PROFILE PANEL (About)
   ══════════════════════════════════════════════════════════════════════ */
.profile-card {
  background: var(--bg-2);
  border: 1px solid var(--rule);
  padding: var(--sp-5);
  line-height: 2;
  transition: background var(--t), border-color var(--t);
}

.profile-card .label { margin-bottom: var(--sp-4); }

.profile-body { color: var(--text-3); }

/* Key/value pairs read as one line each, wrapping if the value is long */
.profile-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0 var(--sp-2);
}

.profile-row dt { flex-shrink: 0; }
.profile-row dd { margin: 0; }

.profile-value { color: var(--text-2); }

.profile-value--accent {
  color: var(--accent);
  transition: color var(--t);
}


/* ══════════════════════════════════════════════════════════════════════
   9. PROJECT CARDS
   ══════════════════════════════════════════════════════════════════════ */

/* 1px gap over a --rule background makes the cards appear to share a
   single hairline grid. */
.project-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  gap: 1px;
  background: var(--rule);
  border: 1px solid var(--rule);
  transition: background var(--t), border-color var(--t);
}

.project-card {
  background: var(--bg);
  padding: var(--sp-6);
  display: flex;
  flex-direction: column;
  transition: background 200ms ease;
}

.project-card:hover { background: var(--bg-1); }

.project-index {
  font-size: var(--fs-xs);
  letter-spacing: 0.15em;
  color: var(--text-3);
  margin-bottom: 1.25rem;
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  transition: color var(--t);
}

.project-live {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}

.project-name {
  font-family: 'Fraunces', Georgia, serif;
  font-size: var(--fs-md);
  font-weight: 400;
  letter-spacing: -0.01em;
  color: var(--text);
  margin-bottom: var(--sp-3);
  line-height: 1.3;
}

.project-desc {
  font-size: var(--fs-sm);
  color: var(--text-2);
  line-height: 1.75;
  flex: 1;
  margin-bottom: var(--sp-5);
}

.project-links {
  display: flex;
  gap: var(--sp-5);
  margin-top: var(--sp-5);
  padding-top: var(--sp-5);
  border-top: 1px solid var(--rule);
  transition: border-color var(--t);
}

.project-link {
  font-size: var(--fs-xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-3);
  text-decoration: none;
  transition: color 200ms;
  display: flex;
  align-items: center;
  gap: 6px;
}

.project-link:hover { color: var(--accent); }

/* Small dot marking a project as currently live */
.active-dot {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--accent-2);
  opacity: 0.8;
  flex-shrink: 0;
  transition: background var(--t);
}

/* ── Loading skeleton ─────────────────────────────────────────────── */
.project-card--skeleton { min-height: 220px; }

.sk-bar {
  background: var(--rule);
  border-radius: 2px;
  animation: skPulse 1.4s ease-in-out infinite;
}

.sk-bar--index { width: 60px;  height: 8px;  background: var(--rule-2); margin-bottom: var(--sp-5); }
.sk-bar--title { width: 180px; height: 14px; background: var(--rule-2); margin-bottom: var(--sp-4); }
.sk-bar--line  { width: 100%;  height: 8px;  margin-bottom: 6px; }
.sk-bar--short { width: 85%;   height: 8px;  }

@keyframes skPulse {
  0%, 100% { opacity: 1;   }
  50%      { opacity: 0.5; }
}

/* ── Notice / empty state ─────────────────────────────────────────── */
.notice {
  border: 1px solid var(--rule);
  padding: var(--sp-6);
  color: var(--text-2);
  line-height: 1.8;
}

.notice-title { margin-bottom: var(--sp-2); }


/* ══════════════════════════════════════════════════════════════════════
   10. SKILL DIAGRAM
   ──────────────────────────────────────────────────────────────────────
   One inline SVG, laid out by assets/js/skill-diagram.js. Every coordinate
   and font size arrives as an attribute from that module, so nothing here
   positions anything: this section is colour, type family and motion only.
   That is the whole point — the CSS version needed three responsive tiers
   and 341 lines to do the same job.

   --bc is set per branch group in the template and resolves to --accent,
   --accent-2 or --accent-3, so each branch keeps the album palette.
   ══════════════════════════════════════════════════════════════════════ */
.skill-diagram { display: flex; justify-content: center; }

.sd {
  display: block;
  width: 100%;
  height: auto;
  margin: var(--sp-4) 0 var(--sp-5);
}

.sd text {
  font-family: 'IBM Plex Mono', ui-monospace, monospace;
  fill: var(--bc, var(--accent));
  transition: fill var(--t);
}

.sd-box {
  fill: var(--bg-2);
  stroke: var(--bc, var(--accent));
  stroke-width: 1;
  transition: fill var(--t), stroke var(--t);
}

.sd-link {
  fill: none;
  stroke: var(--bc, var(--accent));
  stroke-width: 1;
  transition: stroke var(--t);
}

.sd-port { fill: var(--accent); transition: fill var(--t); }

.sd-root .sd-box       { fill: var(--bg-2); stroke: var(--accent); }
.sd-root-primary       { font-family: 'Fraunces', Georgia, serif; fill: var(--text); }
.sd-root .sd-sub       { fill: var(--accent); }
.sd-leaf .sd-label     { fill: var(--bc, var(--accent)); }
.sd-leaf .sd-sub       { fill: var(--text-3); }
.sd-leaf .sd-box       { fill: var(--bg); }
.sd-leaf:hover .sd-box { fill: var(--bg-2); }

/* Signal trace: an overlay copy of each connector. pathLength="1000"
   normalises every path, so one keyframe drives the trunk, the bus, a
   22-unit drop and a 190-unit rail identically, in either layout, with no
   per-element delay maths. That attribute replaces the trace divs, the
   traceV keyframes and six inline animationDelay bindings. */
.sd-trace {
  fill: none;
  stroke: var(--bc, var(--accent));
  stroke-width: 1.5;
  stroke-dasharray: 60 1000;
  animation: sdTrace 2.4s linear infinite;
}

@keyframes sdTrace {
  from { stroke-dashoffset: 1060; }
  to   { stroke-dashoffset: 0; }
}


/* ══════════════════════════════════════════════════════════════════════
   11. SOCIAL LINKS
   ══════════════════════════════════════════════════════════════════════ */
.social-link {
  display: flex;
  gap: var(--sp-6);
  align-items: center;
  justify-content: space-between;
  padding: 1.25rem 0;
  border-bottom: 1px solid var(--rule);
  text-decoration: none;
  transition: border-color var(--t);
}

.social-link:first-of-type { border-top: 1px solid var(--rule); }

.social-platform {
  font-size: var(--fs-xs);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-3);
  width: 80px;
  flex-shrink: 0;
  transition: color var(--t);
}

/* Handles are long unbroken strings (linkedin.com/in/dashcroft). Without
   these two the row's min-content width pushes the whole contact column
   past the viewport on phones. */
.social-handle {
  font-size: var(--fs-sm);
  color: var(--text-2);
  flex: 1;
  min-width: 0;
  overflow-wrap: anywhere;
  transition: color 200ms;
}

.social-arrow {
  font-size: var(--fs-xs);
  color: var(--text-3);
  transition: color 200ms, transform 200ms;
}

.social-link:hover .social-handle { color: var(--text); }
.social-link:hover .social-arrow  { color: var(--accent); transform: translateX(3px); }

@media (max-width: 560px) {
  .social-link     { gap: var(--sp-4); }
  .social-platform { width: 68px; }
}
