/* ============================================================
   0xmrerror.me — components
   ============================================================ */

/* ══ terminal window ═════════════════════════════════════ */
.term {
  position: relative;
  background: var(--bg-1);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--sh-2);
}
/* CRT scanlines, painted *behind* the content: .term-bar and
   .term-body are lifted above this, so glyphs stay fully opaque
   and the lines only darken the panel between them. Decoration
   is not allowed to be a contrast tax on the text, and the
   computed-style audit never sees it -- it measures the elements,
   not what their backgrounds are painted over. The light theme
   sets --scanline-alpha to 0: scanlines on white are dirt. */
.term::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background: repeating-linear-gradient(0deg,
    rgb(0 0 0 / var(--scanline-alpha)) 0 1px, transparent 1px 3px);
}
.term-bar {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 9px var(--sp-4);
  background: var(--bg-2);
  border-bottom: 1px solid var(--line);
}
.term-dots { display: flex; gap: 6px; }
.term-dots i { width: 10px; height: 10px; border-radius: 50%; background: var(--line-strong); }
.term-dots i:nth-child(1) { background: #ff5f57; }
.term-dots i:nth-child(2) { background: #febc2e; }
.term-dots i:nth-child(3) { background: #28c840; }
.term-title {
  flex: 1;
  text-align: center;
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  color: var(--text-faint);
  padding-inline: var(--sp-2);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.term-body { position: relative; z-index: 1; padding: var(--sp-6); }

/* shell prompt */
.prompt {
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  line-height: 1.9;
  word-break: break-word;
}
.prompt .p-user { color: var(--accent-text); font-weight: 500; }
.prompt .p-at   { color: var(--text-faint); }
.prompt .p-host { color: var(--text-faint); }
.prompt .p-colon{ color: var(--text-faint); }
.prompt .p-path { color: var(--accent-2-text); }
.prompt .p-sym  { color: var(--text-faint); }
.prompt .p-cmd  { color: var(--text-hi); }

/* ══ buttons ═════════════════════════════════════════════ */
.btn {
  position: relative;
  overflow: hidden;
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  font-weight: 500;
  line-height: 1;
  padding: 11px var(--sp-5);
  border-radius: var(--r);
  border: 1px solid var(--line-strong);
  background: var(--bg-2);
  color: var(--text-hi);
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--t-fast) var(--ease),
              border-color var(--t-fast) var(--ease),
              color var(--t-fast) var(--ease),
              transform var(--t-fast) var(--ease);
}
/* A diagonal gleam crossing the button on hover. It parks off the
   left edge where the gradient is fully transparent, so it costs
   nothing until a pointer lands. Transform-only: the streak is
   painted once and moved by the compositor. */
.btn::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: -55%;
  width: 40%;
  background: linear-gradient(105deg, transparent, rgb(255 255 255 / .18), transparent);
  transform: skewX(-18deg);
  pointer-events: none;
}
.btn:hover::after { animation: btn-shine .65s var(--ease); }
@keyframes btn-shine {
  from { transform: skewX(-18deg) translateX(0); }
  to   { transform: skewX(-18deg) translateX(400%); }
}
@media (prefers-reduced-motion: reduce) {
  .btn::after { display: none; }
}
.btn:hover { background: var(--bg-3); border-color: var(--accent-line); text-decoration: none; transform: translateY(-1px); }
.btn:active { transform: none; }
.btn--primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
  font-weight: 600;
}
.btn--primary:hover { background: var(--accent); filter: brightness(1.08); border-color: var(--accent); }
.btn--ghost { background: transparent; border-color: var(--line); color: var(--text-dim); }
.btn--ghost:hover { color: var(--accent-text); border-color: var(--accent-line); background: var(--accent-soft); }
.btn--sm { padding: 7px var(--sp-3); font-size: var(--fs-2xs); }
/* the rail's accent/theme switches: a size below --sm, since two of them
   sit stacked in a 216px column under a run of links */
.btn--micro { padding: 4px 9px; font-size: var(--fs-3xs); }
.rail-meta .btn--micro:first-of-type { margin-top: var(--sp-3); }
.btn--icon { padding: 7px; width: 32px; height: 32px; justify-content: center; }
.btn kbd {
  font-size: var(--fs-3xs);
  border: 1px solid var(--line-strong);
  border-bottom-width: 2px;
  border-radius: 3px;
  padding: 1px 5px;
  color: var(--text-faint);
  background: var(--bg-1);
}

/* ══ chips, tags, badges ═════════════════════════════════ */
.tag {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  color: var(--text-dim);
  background: var(--bg-3);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  padding: 3px 8px;
  margin: 2px 4px 2px 0;
  /* Most tags are one or two words and must not break. A few are a whole
     sentence — the CTF result, for one — and at 768px a nowrap chip that
     long pushed the whole document 37px sideways. So: break on overflow
     rather than never. */
  white-space: nowrap;
  max-width: 100%;
  overflow-wrap: break-word;
}
.tag--long { white-space: normal; }
.tag--accent { color: var(--accent-text); border-color: var(--accent-line); background: var(--accent-soft); }
.tag--2 { color: var(--accent-2-text); border-color: hsl(190 85% 60% / .35); background: var(--accent-2-soft); }

.badge-diff {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  font-weight: 500;
  letter-spacing: .06em;
  text-transform: uppercase;
  padding: 2px 7px;
  border-radius: var(--r-sm);
  border: 1px solid;
}
.badge-diff::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.badge-diff[data-diff="easy"]   { color: var(--ok);     border-color: color-mix(in srgb, var(--ok) 45%, transparent);     background: var(--ok-soft); }
.badge-diff[data-diff="medium"] { color: var(--warn);   border-color: color-mix(in srgb, var(--warn) 45%, transparent);   background: var(--warn-soft); }
.badge-diff[data-diff="hard"]   { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); background: var(--danger-soft); }

.badge-platform {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  letter-spacing: .05em;
  text-transform: uppercase;
  padding: 2px 7px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line-strong);
  color: var(--text-dim);
}
.badge-platform[data-platform="thm"]     { color: var(--plat-thm);     border-color: color-mix(in srgb, var(--plat-thm) 40%, transparent); }
.badge-platform[data-platform="htb"]     { color: var(--plat-htb);     border-color: color-mix(in srgb, var(--plat-htb) 40%, transparent); }
.badge-platform[data-platform="picoctf"] { color: var(--plat-picoctf); border-color: color-mix(in srgb, var(--plat-picoctf) 40%, transparent); }

/* ══ generic card ════════════════════════════════════════ */
.card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: var(--sp-6);
  transition: border-color var(--t) var(--ease),
              background var(--t) var(--ease),
              transform var(--t) var(--ease),
              box-shadow var(--t) var(--ease);
}
.card:hover { border-color: var(--line-strong); background: var(--bg-3); transform: translateY(-2px); box-shadow: var(--sh-2); }
.card h3 { font-size: var(--fs-lg); font-weight: 600; }
.card p { color: var(--text-dim); font-size: var(--fs-md); }

/* ── hover spotlight ───────────────────────────────────────
   A pool of accent light that follows the pointer across a card.
   js/main.js writes --mx/--my -- rAF-throttled, one passive
   listener for the page, hover-capable pointers only -- so
   without JS, or on touch, the ::before never lights and the
   card is exactly what it was. The glow is meant to wash over
   the text, the way a lamp washes over a page: at --accent-glow
   (22% alpha) the glyphs underneath keep their colour and
   contrast. */
.card::before, .writeup-card::before, .cert-row::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--t) var(--ease);
  background: radial-gradient(220px circle at var(--mx, 50%) var(--my, 0%),
              var(--accent-glow), transparent 68%);
}
.card:hover::before, .card:focus-within::before,
.writeup-card:hover::before, .writeup-card:focus-within::before,
.cert-row:hover::before, .cert-row:focus-within::before { opacity: 1; }
/* A tap leaves :hover stuck on, which would park the glow at its
   last position for good -- same failure the room zoom has. */
@media (hover: none) {
  .card::before, .writeup-card::before, .cert-row::before { display: none; }
}

/* ── stat strip ─────────────────────────────────────────
   Moved out of its own section and into the hero, so the block closes on the
   numbers that back the headline instead of leaving a near-empty band between
   the hero and whoami.

   Editorial, not terminal: Inter at display size behind hairline rules rather
   than another row of mono chips. By this point the reader has already had the
   mono treatment from the eyebrow and the pipeline card, and a third helping
   is what made the old page read as one flat texture.

   `white-space: nowrap` on the value is load-bearing -- "Top 1%" otherwise
   breaks across the space once the tracks narrow, leaving a bare "%" under a
   "Top 1" and looking broken. */
.stat-strip {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  margin-top: clamp(var(--sp-10), 5vw, var(--sp-16));
  padding-top: var(--sp-6);
  border-top: 1px solid var(--line);
}
.stat {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  padding-right: var(--sp-4);
  text-align: left;
}
.stat + .stat {
  border-left: 1px solid var(--line-soft);
  padding-left: clamp(var(--sp-4), 2.5vw, var(--sp-7));
}
.stat b {
  font-family: var(--font-body);
  font-size: var(--fs-4xl);
  font-weight: 700;
  letter-spacing: -.03em;
  line-height: 1;
  color: var(--text-hi);
  white-space: nowrap;
  /* the count-up animation (js/main.js) rolls through these digits;
     tabular figures keep every step the same width so nothing shifts */
  font-variant-numeric: tabular-nums;
}
.stat span { font-size: var(--fs-2xs); color: var(--text-faint); }

@media (max-width: 760px) {
  .stat-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-6) 0; }
  .stat + .stat { border-left: 0; padding-left: 0; }
  .stat:nth-child(even) { border-left: 1px solid var(--line-soft); padding-left: var(--sp-5); }
}

/* ══ about ═══════════════════════════════════════════════ */
.about-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1px;
  background: var(--line);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  overflow: hidden;
  margin-bottom: var(--sp-6);
}
/* two columns stop being useful once a cell is narrower than its label
   plus the shortest value that has to sit beside it */
@media (max-width: 560px) { .about-grid { grid-template-columns: 1fr; } }
.about-field { background: var(--bg-2); padding: var(--sp-4) var(--sp-5); }
.about-field dt {
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  color: var(--text-faint);
  text-transform: uppercase;
  letter-spacing: .08em;
  margin-bottom: 3px;
}
.about-field dd { font-size: var(--fs-sm); color: var(--text-hi); }
/* ── the bio ────────────────────────────────────────────
   Four short paragraphs instead of one 1,000-character block, which ran to
   sixteen lines on a phone with no landmark anywhere in it. The sentences are
   the same ones. The lead is a standfirst -- one step up in size, at
   --text-hi -- so the summary is findable on the way past; the rest stays at
   body colour rather than being dimmed, which reads as disabled text. */
.about-bio > p + p { margin-top: var(--sp-5); }
.about-bio > p:first-child {
  font-size: var(--fs-xl);
  line-height: 1.6;
  color: var(--text-hi);
}
.about-badges { display: flex; flex-wrap: wrap; gap: var(--sp-3); margin-top: var(--sp-6); }

/* The portrait sits at the top of the whoami terminal, floated right of
   the handle/location grid. Float, not grid, so it needs no restructure
   of .about-grid and drops out of flow on a phone without a second
   layout: below 560px it becomes a centred block above the fields,
   which is the width the grid collapses to anyway. */
.about-portrait {
  float: right;
  width: 172px;
  height: 172px;
  object-fit: cover;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  margin: 0 0 var(--sp-4) var(--sp-6);
}
@media (max-width: 560px) {
  .about-portrait { display: block; float: none; margin: 0 auto var(--sp-5); }
}
.rank-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  font-family: var(--font-mono);
  font-size: var(--fs-2xs);
  background: var(--bg-2);
  border: 1px solid var(--accent-line);
  border-radius: var(--r);
  padding: var(--sp-3) var(--sp-4);
  color: var(--text-dim);
}
.rank-badge b { color: var(--accent-text); font-weight: 600; }
.rank-badge small { color: var(--text-faint); font-size: var(--fs-3xs); }

/* ══ skills ══════════════════════════════════════════════ */
.skill-group { gap: var(--sp-4); }
.skill-group-head { display: flex; align-items: center; gap: var(--sp-3); }
.skill-group-head .ico {
  width: 34px; height: 34px;
  display: grid; place-items: center;
  border-radius: var(--r);
  background: var(--accent-soft);
  border: 1px solid var(--accent-line);
  font-size: 15px;
  flex-shrink: 0;
}
.skill-group h3 { font-size: var(--fs-md); font-weight: 600; }
.skill-list { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.skill-list li { margin: 0; }

/* ══ experience timeline ═════════════════════════════════ */
.timeline { position: relative; display: flex; flex-direction: column; gap: var(--sp-10); }
.timeline::before {
  content: '';
  position: absolute;
  left: 5px; top: 6px; bottom: 6px;
  width: 1px;
  background: linear-gradient(var(--accent-line), var(--line) 40%, transparent);
}
.tl-item { position: relative; padding-left: var(--sp-8); }
.tl-item::before {
  content: '';
  position: absolute;
  left: 0; top: 7px;
  width: 11px; height: 11px;
  border-radius: 50%;
  background: var(--bg-0);
  border: 2px solid var(--accent);
  box-shadow: 0 0 0 4px var(--accent-soft);
}
.tl-top { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3) var(--sp-4); }
.tl-heading { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.tl-org { font-size: var(--fs-lg); font-weight: 600; color: var(--text-hi); }
.tl-when { font-family: var(--font-mono); font-size: var(--fs-2xs); color: var(--text-faint); }
.tl-role { font-size: var(--fs-md); color: var(--accent-text); margin-top: 2px; }
.tl-item ul { margin-top: var(--sp-4); }
.tl-item li { font-size: var(--fs-md); color: var(--text-dim); }
.tl-item li::marker { color: var(--accent); }

.edu-list { display: grid; gap: var(--sp-4); margin-top: var(--sp-8); }
.edu-item {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3) var(--sp-4);
  padding: var(--sp-4) var(--sp-5);
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: var(--r);
}
.edu-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2) var(--sp-3); flex: 1 1 260px; min-width: 0; }
.edu-item b { color: var(--text-hi); font-weight: 600; }
.edu-item .deg { color: var(--text-dim); font-size: var(--fs-md); }
.edu-item .note { flex-basis: 100%; font-size: var(--fs-sm); color: var(--text-faint); }

/* An organisation's mark, as an avatar-style block beside its name.

   At 96px it is no longer an inline glyph, so every row that carries one is
   a flex row built around it: mark on the left, name and period stacked in
   .tl-heading/.edu-heading beside it. The sources are white-cornered JPEGs
   (and one transparent SVG), so all marks get the same white chip: without
   it the white-backed ones are bright squares on the dark theme and the
   transparent one loses its blues. Decorative -- alt is empty in the markup
   because the adjacent text already names the organisation. */
.org-logo {
  display: block;
  height: 96px;
  width: auto;
  flex: none;
  background: #fff;
  padding: 5px;
  border: 1px solid var(--line);
  border-radius: 8px;
}
/* The mark replaces the timeline dot when a row has one: a 96px logo and an
   11px dot on the same left edge fight for the eye, and the logo already
   marks the row. :has() is fine here -- the stylesheet already requires
   @property, aspect-ratio and overflow: clip. */
.tl-item:has(.org-logo) { padding-left: 0; }
.tl-item:has(.org-logo)::before { display: none; }
@media (max-width: 560px) {
  .org-logo { height: 72px; }
}

/* ══ projects ═════════════════════════════════════════════ */
.project-card { gap: var(--sp-4); }
/* --ang is registered so the conic gradient's angle can animate;
   where @property is unsupported the border still paints, it just
   never walks. */
@property --ang { syntax: '<angle>'; initial-value: 0deg; inherits: false; }
.project-card--featured {
  /* The flagship is a full-width row of its own: diagram on the left, copy
     on the right, so it reads as the lead project without the tall
     full-bleed banner that used to dominate the section. */
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr);
  align-items: start;
  gap: var(--sp-6);
  background:
    linear-gradient(180deg, var(--accent-soft), transparent 42%),
    var(--bg-2);
  border-color: var(--accent-line);
}
@media (max-width: 720px) {
  .project-card--featured { grid-template-columns: 1fr; gap: var(--sp-4); }
}
/* A hairline of accent walking around the featured card's edge.
   The mask keeps the middle empty: content-box then full box, with
   the two masks excluding each other, leaves only the 1px ring.
   The card's own border sits underneath; this rides on top of it. */
.project-card--featured::after {
  content: '';
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  padding: 1px;
  background: conic-gradient(from var(--ang),
    var(--accent-line), var(--accent-2), var(--line-strong), var(--accent-line));
  -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;
  pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  .project-card--featured::after { animation: border-walk 7s linear infinite; }
}
@keyframes border-walk { to { --ang: 360deg; } }
.project-head { display: flex; align-items: flex-start; gap: var(--sp-3); }
.project-head h3 { flex: 1; }
.project-flag {
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--accent-ink);
  background: var(--accent);
  padding: 3px 8px;
  border-radius: var(--r-sm);
  font-weight: 600;
  white-space: nowrap;
}
.project-metrics { display: flex; flex-wrap: wrap; gap: 6px; }
.project-metrics span {
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  color: var(--text-dim);
  border: 1px dashed var(--line-strong);
  border-radius: var(--r-sm);
  padding: 2px 7px;
}
.project-foot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: auto; padding-top: var(--sp-2); }
.project-state {
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  color: var(--text-faint);
  margin-left: auto;
}

/* ── project media ────────────────────────────────────────
   A 16:9 frame per project. The source art is 1672x941 (1.776, effectively
   16:9), so object-fit: cover crops nothing -- the frame exists to make the
   standard cards line up and to keep the images responsive. The overflow
   clip is what lets the 3% hover zoom sit inside the frame instead of
   spilling over the card's border. */
.project-media {
  display: block;
  overflow: hidden;
  aspect-ratio: 16 / 9;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--bg-inset);
}
.project-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 150ms var(--ease);
}
.project-card:hover .project-img { transform: scale(1.03); }
@media (prefers-reduced-motion: reduce) {
  .project-img { transition: none; }
  .project-card:hover .project-img { transform: none; }
}
/* The copy is a flex column so its footer can be pushed to the bottom of
   a card taller than its content. */
.project-body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  flex: 1;
  min-width: 0;
}
/* The overflow chip for tags beyond the fourth: dashed like the metric
   chips so it reads as "more of the same", and its title carries the full
   list. Yes, hover-only on a phone -- the first four tags are the ones that
   communicate the stack; this is a pointer affordance, not content. */
.tag--more {
  color: var(--text-faint);
  background: transparent;
  border-style: dashed;
  cursor: help;
}

/* ══ certifications ═════════════════════════════════════════
   A list of rows, not a grid of cards.

   It was a three-column grid, and seven of the eight certificates had an
   identical six-line shape with only the text differing -- the eighth, the
   CCNA, had four lines, so the bottom row was ragged. A grid of near copies buys
   nothing a list does not, and cost 1190px of page to say what 8 rows say in
   about 380. The row reads left to right the way the certificate does: who
   issued it and when, what it is, then the certificate itself at the right,
   growing on hover exactly as the room artwork does on a write-up card. */
.cert-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
}
.cert-row {
  /* 64px -> 112px on hover. The row is padded so the zoomed thumbnail still
     fits inside its own row rather than reaching into the neighbouring ones:
     a uniform scale on an aspect-preserving image is always taller than the
     space it started in, and the only place that height can come from is the
     row's own padding. */
  --cert-thumb-w: 64px;
  --cert-thumb-h: 46px;
  --cert-thumb-zoom: 1.7;     /* 64px -> 109px, 46px -> 78px: the row is 80px
                                tall and the zoom has to finish inside it */
  position: relative;
  display: grid;
  /* 13.5rem fits "CISCO NETWORKING ACADEMY" on one line at 11px mono with
     the letter-spacing; at 11rem it wrapped and made one row 14px taller than
     the other seven. */
  grid-template-columns: 13.5rem 1fr 76px;
  align-items: center;
  gap: var(--sp-5);
  padding: var(--sp-4) var(--sp-5);
  /* base.css gives every li margin-block: 8px, which on top of the flex gap
     would put 24px between rows */
  margin-block: 0;
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: var(--r);
  transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.cert-row:hover { border-color: var(--accent-line); background: var(--bg-3); }

/* issuer + date, as the left-hand label column */
.cert-side { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.cert-issuer {
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--text-dim);
}
.cert-date { font-family: var(--font-mono); font-size: var(--fs-3xs); color: var(--text-faint); }

.cert-main { min-width: 0; }
.cert-name { font-size: var(--fs-md); font-weight: 600; line-height: 1.4; color: var(--text-hi); }
.cert-foot { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2) var(--sp-3); margin-top: 2px; }
.cert-skills { font-size: var(--fs-2xs); color: var(--text-faint); }
.cert-id {
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  color: var(--text-faint);
  word-break: break-all;
}

/* The thumbnail. 64px at rest is the largest size that still reads as a
   detail rather than a half-loaded page; 112px on hover is enough of the
   certificate to recognise it without opening the overlay.

   The column is a fixed 76px and the image is fitted inside a 64x46 box with
   object-fit rather than sized from its own ratio. Seven of the eight
   certificates are A4 landscape and one -- the NVIDIA record -- is a portrait
   page, which at a width-driven 64px would be 90px tall and would make its row
   44px taller than the other seven. Sizing to the box instead keeps all eight
   rows the same height and lets the portrait sit letterboxed in the middle of
   its cell, which is what it is. */
.cert-thumb { display: flex; align-items: center; justify-content: flex-end; }
.cert-thumb-img {
  width: var(--cert-thumb-w);
  height: var(--cert-thumb-h);
  object-fit: contain;
  display: block;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  box-shadow: var(--sh-1);
  opacity: .62;
  transform: scale(1);
  transform-origin: right center;
  transition: transform var(--t) var(--ease), opacity var(--t) var(--ease), box-shadow var(--t) var(--ease);
}
.cert-row:hover .cert-thumb-img,
.cert-row:focus-within .cert-thumb-img {
  opacity: 1;
  transform: scale(var(--cert-thumb-zoom));
  box-shadow: var(--sh-2);
}

/* The whole row opens the certificate, so the link is stretched over the row
   rather than being a one-word target in the corner. It sits above the row
   content (z-index 1) so a click anywhere on the row registers -- except on the
   certificate id, which is lifted above the link (z-index 3) so that dragging
   across it to copy the id does not open the overlay instead. */
.cert-open {
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
  text-decoration: none;
}
.cert-open:focus-visible { outline: 2px solid var(--accent-text); outline-offset: -2px; }
.cert-id { position: relative; z-index: 3; }
/* Same reasoning as .cert-id above: the stretched .cert-open covers the
   whole row, so anything inside it that must take the click itself needs
   to sit above it. */
.cert-verify {
  position: relative;
  z-index: 3;
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  color: var(--accent-text);
}
.cert-verify:hover { text-decoration: underline; }

/* Narrow screens drop the label column first: issuer and date move under the
   name, where there is width for them, and the thumbnail shrinks rather than
   pushing the name into a two-word-per-line column. */
@media (max-width: 720px) {
  .cert-row { grid-template-columns: 1fr auto; gap: var(--sp-3) var(--sp-4); }
  .cert-main { grid-row: 1; }
  .cert-thumb { grid-row: 1 / span 2; }
  .cert-side {
    grid-row: 2;
    flex-direction: row;
    align-items: baseline;
    gap: var(--sp-2);
  }
  .cert-thumb-img { --cert-thumb-w: 44px; --cert-thumb-h: 32px; }
}

/* Same reasoning as .room-icon--card: the zoom is the default and the pointer
   type takes it away, so nothing depends on a media query the browser has to
   volunteer correctly. */
@media (hover: none) {
  .cert-thumb-img,
  .cert-row:hover .cert-thumb-img { transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .cert-thumb-img,
  .cert-row:hover .cert-thumb-img { transform: none; }
}


/* ══ certificate viewer ═══════════════════════════════════ */
.certbox {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(var(--sp-4), 5vh, var(--sp-8)) var(--gutter);
  background: var(--scrim);
  backdrop-filter: blur(6px);
  overflow-y: auto;
}
.certbox[hidden] { display: none; }
.certbox-panel {
  position: relative;
  width: 100%;
  max-width: 1000px;
  background: var(--bg-1);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  box-shadow: var(--sh-3);
  overflow: hidden;
}
/* The image is white paper on a dark page; a white mat stops it floating and
   gives it an edge to sit on. */
.certbox-shot {
  background: #fff;
  padding: var(--sp-4);
  min-height: 120px;
}
.certbox-shot img { display: block; width: 100%; height: auto; }
.certbox.is-loading .certbox-shot { min-height: 240px; }
.certbox-x {
  position: absolute;
  top: var(--sp-2);
  right: var(--sp-2);
  z-index: 1;
  width: 30px;
  height: 30px;
  display: grid;
  place-items: center;
  font-size: var(--fs-2xs);
  color: var(--text-hi);
  background: var(--bg-3);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  cursor: pointer;
  transition: background var(--t) var(--ease), color var(--t) var(--ease);
}
.certbox-x:hover { background: var(--accent-soft); color: var(--accent-text); }
.certbox-cap {
  padding: var(--sp-3) var(--sp-4);
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  color: var(--text-dim);
  border-top: 1px solid var(--line);
}
body.has-overlay { overflow: hidden; }

/* ══ writeup cards ═══════════════════════════════════════ */
.writeup-list { display: flex; flex-direction: column; gap: var(--sp-2); }
.writeup-card {
  --arrow-w: 12px;
  position: relative;
  display: grid;
  /* The third track is the room slot. It is reserved on every card, whether or
     not the card has art in it: only the eight TryHackMe rooms do, so a track
     sized to its content would give the five picoCTF cards a wider summary and
     wrap their text differently for no reason a reader could name. */
  grid-template-columns: auto 1fr var(--room-w) auto;
  align-items: center;
  gap: var(--sp-4);
  padding: var(--sp-5);
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: var(--r);
  transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
}
.writeup-card:hover {
  border-color: var(--accent-line);
  background: var(--bg-3);
  text-decoration: none;
  transform: translateX(3px);
}
/* The zoomed artwork is allowed to leave the card. Two other rules already keep
   it above its neighbours -- .room-icon--card's own z-index, and the stacking
   context the hover transform creates around the card -- so this states the
   ordering rather than repairs it. That was checked by painting the
   neighbouring card magenta and reading back the pixels under the 8px band the
   zoom overruns into: the artwork wins with this rule and without it. It is
   here because the ordering is otherwise a side effect of two rules that have
   no reason to know about each other, and one of them is a hover transform. */
.writeup-card:hover,
.writeup-card:focus-visible { z-index: var(--z-raised); }
.writeup-idx { font-family: var(--font-mono); font-size: var(--fs-2xs); color: var(--accent-2-text); }
.writeup-body { min-width: 0; }
.writeup-title { font-size: var(--fs-md); font-weight: 600; color: var(--text-hi); }
.writeup-sum {
  font-size: var(--fs-sm);
  color: var(--text-dim);
  margin-top: 2px;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.writeup-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 7px; }

/* Room artwork.

   These files are not logo marks. Each one is a screenshot of TryHackMe's dark
   room UI: a fully opaque PNG with #212c42/#141b2a baked into it, artwork over
   ~88%x84% of the canvas, and a gradient in the background that means flood
   filling the corners out clears only 1.5-4% of the pixels. A dark rectangle
   cannot be made transparent, so it is framed as what it is -- a thumbnail of
   a room, with a hairline edge and dimmed until you go looking for it.

   Sized by width with the height following the intrinsic ratio, so the art is
   never squashed and the row height does not depend on the file. */
.room-icon {
  width: var(--room-w);
  height: auto;
  flex: none;
  align-self: center;
  opacity: .5;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  box-shadow: var(--sh-1);
  transform: scale(1);
  transform-origin: right center;
  transition: transform var(--t) var(--ease), opacity var(--t) var(--ease), box-shadow var(--t) var(--ease);
}

/* On the card: taken out of flow and laid over the slot the grid reserved for
   it, so growing the image cannot reflow anything. The offset is the same sum
   the grid is made of -- padding-right, then the gap and the arrow width -- so
   the reserved track and the image sit in the same place whether or not there
   is an image to fill it. */
.room-icon--card {
  position: absolute;
  right: calc(var(--sp-5) + var(--sp-4) + var(--arrow-w));
  z-index: 1;
}

/* On the write-up's own page, where there is no card and no hover target worth
   having: the art is the header image, so it is just large. Sized by its own
   token rather than as a multiple of --room-w -- see the note there. */
.room-icon--head { width: var(--room-w-head); opacity: .8; }

/* The gesture: 76px becomes 205px, growing leftwards out of the right-hand
   slot (transform-origin: right center) so it stays put instead of sliding
   across the text. It is meant to overrun the card: anchored at the right and
   centred vertically, it passes over the card above and the card below rather
   than being clipped by either, so the z-index note above is what governs what
   paints on top in that band.

   Written as ":hover does it, hover:none takes it away" rather than the other
   way round. Both describe the same rule, but gating on (hover: hover) means
   any browser that under-reports the pointer -- which headless Chromium does,
   so the check below would never have exercised it -- silently loses the
   feature instead of failing loudly. :focus-visible is alongside it so the
   zoom is reachable from the keyboard, where the card's own focus ring already
   is. */
.writeup-card:hover .room-icon--card,
.writeup-card:focus-visible .room-icon--card {
  opacity: 1;
  transform: scale(var(--room-zoom));
  box-shadow: var(--sh-2);
}
/* A tap on a touch screen can leave :hover stuck on, which would park the
   image at 205px for good. */
@media (hover: none) {
  .room-icon--card,
  .writeup-card:hover .room-icon--card { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .room-icon--card,
  .writeup-card:hover .room-icon--card { transform: none; }
}
.writeup-time { font-family: var(--font-mono); font-size: var(--fs-3xs); color: var(--text-faint); }
/* Fixed width, so .room-icon--card can offset itself from the card's right edge
   by a known amount instead of guessing at the width of a glyph. */
.writeup-arrow { width: var(--arrow-w); text-align: right; color: var(--text-faint); transition: transform var(--t) var(--ease), color var(--t) var(--ease); }
.writeup-card:hover .writeup-arrow { color: var(--accent-text); transform: translateX(3px); }
.writeup-card.is-hidden { display: none; }

/* the "all N write-ups →" button under the homepage's featured five */
.archive-cta { margin-top: var(--sp-6); }

/* Below this the summary column is the first thing to be squeezed -- it is the
   1fr track, so every pixel of --room-w comes out of it -- and a two-line
   clamp turns into a three-line one. The artwork gives some back: 58px, and a
   lower zoom factor because the overrun has nowhere to go at this width.
   --room-w-head is not scaled here: the header row wraps rather than clips, so
   a slightly smaller header image costs nothing. */
@media (max-width: 560px) {
  .writeup-card { --room-w: 58px; --room-zoom: 2.4; --arrow-w: 10px; }
  .room-icon--head { width: 96px; }
}

/* filter bar: one labelled group per line */
.filters {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--sp-2);
  margin-bottom: var(--sp-6);
}
.filter-group { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.filter-set { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); }
.filter-label {
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  text-transform: uppercase;
  letter-spacing: .1em;
  color: var(--text-faint);
  /* fixed so every group's buttons start at the same x */
  min-width: 8ch;
  text-align: right;
}
.filter-btn {
  font-family: var(--font-mono);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  background: transparent;
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  padding: 5px var(--sp-3);
  cursor: pointer;
  transition: all var(--t-fast) var(--ease);
}
.filter-btn:hover { color: var(--text-hi); border-color: var(--line-strong); }
.filter-btn[aria-pressed="true"] {
  color: var(--accent-ink);
  background: var(--accent);
  border-color: var(--accent);
  font-weight: 600;
}
.filter-count { font-family: var(--font-mono); font-size: var(--fs-3xs); color: var(--text-faint); margin-top: var(--sp-1); }

/* ══ pipeline diagram ════════════════════════════════════ */
/* One labelled row per phase, each row a chain of boxes with arrows between
   them. The chain used to be a single wrapping flex list of all sixteen boxes,
   which wrapped into rows that had nothing to do with the five phases the
   surrounding copy referred to. The phase label is what orders them now, and
   each row wraps on its own.

   The arrow is painted on the step's ::after rather than sitting in the flow,
   so a row never opens with a stray arrow. `overflow: clip` is load-bearing:
   a row that wraps puts an arrow 28px past its last box, and in the old
   single-chain version that arrow pushed the whole document sideways on a
   phone -- 382px of content in a 375px viewport. Clipping the row drops the
   straggler without introducing a scrollbar, and leaves the arrows that sit in
   the gaps between boxes alone, because those are inside the row. */
.phases { margin-top: var(--sp-6); }
.phase + .phase { margin-top: var(--sp-6); }
.phase-label {
  display: flex;
  align-items: baseline;
  gap: var(--sp-3);
  font-family: var(--font-mono);
  font-size: var(--fs-2xs);
  color: var(--text-dim);
  margin-bottom: var(--sp-3);
}
.phase-label span {
  font-size: var(--fs-3xs);
  color: var(--accent-text);
  text-transform: uppercase;
  letter-spacing: .1em;
}
.pipeline {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: var(--sp-5) var(--sp-7);
  overflow: clip;
}
.stage {
  position: relative;
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: var(--r);
  padding: var(--sp-3) var(--sp-4);
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 128px;
  flex: 1 1 128px;
  /* grow to share a full row, but never balloon: a last row with two boxes
     left would otherwise stretch them to half the container each */
  max-width: 240px;
  transition: border-color var(--t) var(--ease), background var(--t) var(--ease);
}
.stage:not(:last-child)::after {
  content: '\2192';             /* → */
  position: absolute;
  inset-inline-end: calc(-1 * var(--sp-7));
  top: 50%;
  translate: 0 -50%;
  font-family: var(--font-mono);
  font-size: var(--fs-2xs);
  color: var(--text-faint);
}
.stage:hover { border-color: var(--accent-line); background: var(--bg-3); }
.stage .n {
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  color: var(--accent-text);
  letter-spacing: .1em;
}
.stage b { font-size: var(--fs-2xs); color: var(--text-hi); font-weight: 600; line-height: 1.35; }
.stage .tool {
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  color: var(--accent-2-text);
  /* build.py inserts a <wbr> at every underscore, so the only places this can
     break are the ones a reader would pick. The break-word is a floor under
     that rather than the mechanism: if a future module name has no separator at
     all it still cannot overflow its box. */
  overflow-wrap: break-word;
}
/* the live window: this step runs the sample instead of reasoning about it */
.stage.is-live { border-color: var(--accent-line); background: var(--accent-soft); }
.stage.is-live b { color: var(--accent-text); }

/* narrow: the row gap costs more than it buys once the boxes are stacked
   two-up, and 16 boxes over 8 rows is a long scroll */
@media (max-width: 560px) {
  .pipeline { gap: var(--sp-2) var(--sp-5); }
  .stage { padding: var(--sp-2) var(--sp-3); min-width: 120px; flex-basis: 120px; }
}

/* ══ command palette ═════════════════════════════════════ */
.palette {
  position: fixed;
  inset: 0;
  z-index: var(--z-palette);
  display: none;
  align-items: flex-start;
  justify-content: center;
  padding: clamp(60px, 14vh, 140px) var(--gutter) var(--gutter);
  background: var(--scrim);
  backdrop-filter: blur(6px);
}
.palette.is-open { display: flex; }
.palette-box {
  width: 100%;
  max-width: 620px;
  background: var(--bg-1);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  box-shadow: var(--sh-3);
  overflow: hidden;
}
.palette-input {
  width: 100%;
  border: 0;
  border-bottom: 1px solid var(--line);
  background: var(--bg-2);
  color: var(--text-hi);
  font-family: var(--font-mono);
  font-size: var(--fs-md);
  padding: var(--sp-4) var(--sp-5);
}
.palette-input::placeholder { color: var(--text-faint); }
.palette-input:focus { outline: none; }
.palette-list { list-style: none; margin: 0; padding: var(--sp-2); max-height: 46vh; overflow-y: auto; }
.palette-item {
  display: flex; align-items: center; gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  border-radius: var(--r);
  cursor: pointer;
  font-size: var(--fs-sm);
  color: var(--text);
}
.palette-item .k { font-family: var(--font-mono); font-size: var(--fs-3xs); color: var(--text-faint); margin-left: auto; }
.palette-item[aria-selected="true"], .palette-item:hover { background: var(--accent-soft); color: var(--text-hi); }
.palette-item mark { background: transparent; color: var(--accent-text); font-weight: 600; }
.palette-empty { padding: var(--sp-6); text-align: center; color: var(--text-faint); font-family: var(--font-mono); font-size: var(--fs-2xs); }
.palette-foot {
  display: flex; gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-5);
  border-top: 1px solid var(--line);
  font-family: var(--font-mono);
  font-size: var(--fs-3xs);
  color: var(--text-faint);
}
.palette-foot b { color: var(--text-dim); font-weight: 500; }

/* ══ toast ═══════════════════════════════════════════════ */
.toast {
  position: fixed;
  left: 50%;
  bottom: var(--sp-8);
  transform: translate(-50%, 20px);
  z-index: var(--z-toast);
  display: flex; align-items: center; gap: var(--sp-2);
  background: var(--bg-3);
  border: 1px solid var(--accent-line);
  color: var(--text-hi);
  border-radius: var(--r-pill);
  padding: 9px var(--sp-5);
  font-family: var(--font-mono);
  font-size: var(--fs-2xs);
  box-shadow: var(--sh-3);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t) var(--ease), transform var(--t) var(--ease);
}
.toast.is-on { opacity: 1; transform: translate(-50%, 0); }

/* ── column headings ─────────────────────────────────────────────────────
   Small uppercase mono labels that sit above a sub-column.  Was an inline
   style repeated five times across three templates.                      */
.col-head {
  font-family: var(--font-mono);
  font-size: var(--fs-md);
  font-weight: 500;
  color: var(--text-faint);
  text-transform: uppercase;
  letter-spacing: .1em;
  margin-bottom: var(--sp-5);
}
.col-head--spaced { margin: var(--sp-8) 0 var(--sp-5); }

/* A section heading that belongs to a panel rather than a top-level band. */
.section-head--panel { margin-top: var(--sp-16); }
.section-head--panel .col-head { font-size: var(--fs-xl); }

/* the small label lists in the experience column (clubs, languages) */
.fact-line { font-size: var(--fs-sm); margin-bottom: var(--sp-3); }
/* the last two groups are the only ones left, and they are the ones a
   reader actually scans for — give them the room to do it */
.fact-line--lg { font-size: var(--fs-md); margin-bottom: var(--sp-4); }
.fact-line--lg .tag { font-size: var(--fs-2xs); padding: 5px 10px; }

/* ── contact panel: the "elsewhere" list ─────────────────────
   Aligned `label  value` rows. Was four inline styles per line;
   these are the classes that replaced them.                  */
.elsewhere-line { font-size: var(--fs-sm); margin-bottom: var(--sp-4); }
.elsewhere-key {
  font-family: var(--font-mono);
  font-size: var(--fs-2xs);
}

/* ── cv.html ────────────────────────────────────────────────
   The CV is a flat document: headings, bordered-free entries and
   smaller body text. Also inline styles until now.            */
.cv-h3 {
  font-family: var(--font-mono);
  font-size: var(--fs-2xs);
  color: var(--accent-text);
  text-transform: uppercase;
  letter-spacing: .1em;
  margin: var(--sp-8) 0 var(--sp-3);
}
.cv-entry { margin-bottom: var(--sp-4); }
.cv-stack { font-size: var(--fs-2xs); }
.cv-desc { font-size: var(--fs-sm); }
.cv-list { font-size: var(--fs-sm); }
.edu-item--flat {
  border: 0;
  border-bottom: 1px solid var(--line);
  border-radius: 0;
  padding: var(--sp-3) 0;
}
.tl-item--flat { padding-left: 0; margin-bottom: var(--sp-6); }
