/* ============================================================
   MUHAMMAD ALMUWARISIN — PORTFOLIO
   Editorial minimalism: one accent, one radius, hairline rules.
   ------------------------------------------------------------
   Every colour lives here. Never hardcode one in a component.
   Brand purple is rationed to six places (see AGENTS.md):
     1. surname in the navbar
     2. active nav link underline
     3. ONE solid CTA (hero only)
     4. inline links + focus ring
     5. project index (01 / 02)
     6. one 16x2 accent rule per section head
   ============================================================ */

/* ---------- RESET ----------
   The subset of normalize.css this site actually used. Loading the
   CDN version cost a render-blocking request for ~8 rules. */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  min-height: 100vh;
}

ul,
ol {
  list-style: none;
}

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

img {
  height: auto;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  cursor: pointer;
}

strong {
  font-weight: 600;
}

[hidden] {
  display: none;
}

/* ---------- TOKENS ---------- */
:root {
  /* brand */
  --brand: #7c3aed;          /* solid CTA fill; white text = 5.70:1 */
  --brand-hover: #6d28d9;
  --brand-ink: #6d28d9;      /* brand as TEXT on --bg (7.10:1) */

  /* surfaces */
  --bg: #ffffff;
  --bg-sunken: #fafafa;      /* only: screenshot letterbox, hover wash */

  /* ink */
  --ink: #18181b;            /* 17.72:1 */
  --ink-2: #52525b;          /*  7.73:1 */
  --ink-3: #71717a;          /*  4.82:1 */

  /* rules */
  --rule: #e4e4e7;           /* hairline */
  --rule-2: #d4d4d8;         /* hairline, hover / stronger */

  /* type */
  --font: Inter, system-ui, -apple-system, sans-serif;
  /* System mono for metadata. Zero network requests. */
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;

  --t-display: clamp(2.125rem, 5.4vw, 3.25rem);
  --t-lede: clamp(1.0625rem, 1.5vw, 1.1875rem);
--t-h2: 1.375rem;
--t-h3: 1.0625rem;
/* Project titles are their own step: 20px standard, 24px for the featured
   entry only. Named so the two-step hierarchy is on the scale rather than
   two stray rem values inside the component. */
--t-project: 1.25rem;
--t-project-lg: 1.5rem;
--t-body: 1.0625rem;
  --t-sm: 0.9375rem;
  --t-meta: 0.8125rem;
  --t-mono: 0.75rem;

  /* space */
  --s-1: 4px;
  --s-2: 8px;
  --s-3: 16px;
  --s-4: 24px;
  --s-5: 40px;
  --s-6: 64px;
  --s-7: 96px;

  /* measure */
  --measure: 1040px;         /* outer container */
  --col: 62ch;               /* prose measure */

  /* ONE radius: buttons, tags, media only. Cards and rows are square. */
  --radius: 4px;

  --t: 180ms cubic-bezier(0.2, 0, 0, 1);

  /* Single entrance token. 180ms is too fast to read as a sequence; one shared
     600ms step with staggered delays keeps the whole hero under ~1.2s. */
  --t-enter: 600ms cubic-bezier(0.2, 0, 0, 1);

  /* Scrim behind the open drawer: the page blurs while navigation has focus.
     Theme-dependent wash, so it lives here rather than as a literal. */
  --scrim: rgba(24, 24, 27, 0.32);

  /* Portrait framing. profile/profile.webp is upright 800x1200 with no EXIF
     Orientation tag, so no browser corrects it and no CSS rotate() can fix it
     either. Plain centring lands on the face. */
  --portrait-focus: 50% 50%;
}

[data-theme='dark'] {
  --brand-ink: #c084fc;      /* 7.44:1 on #0B0B0F */

  --bg: #0b0b0f;
  --bg-sunken: #15151c;
  --ink: #f4f4f5;            /* 17.87:1 */
  --ink-2: #a1a1aa;          /*  7.67:1 */
  --ink-3: #8a8a93;          /*  5.74:1 */
  --rule: #212129;
  --rule-2: #2f2f3a;
  --scrim: rgba(0, 0, 0, 0.6);
}

/* ---------- BASE ---------- */
body {
  font-family: var(--font);
  font-size: var(--t-body);
  line-height: 1.65;
  background: var(--bg);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  transition: background 200ms ease, color 200ms ease;
}

html[data-theme='dark'] {
  color-scheme: dark;
}

/* Metadata runs in the system mono stack. Inter only is still true — this
   costs no font request and gives the page a second, quieter voice. */
.eyebrow,
.hero-stack,
.fact-label,
.project-index,
.project-meta dt,
.edu-meta,
.tech-inline li,
.contact-label,
.contact-value,
.footer-links,
.hero-figure figcaption {
  font-family: var(--mono);
}

.eyebrow {
  font-size: var(--t-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  line-height: 1.6;
}

/* ---------- FOCUS ----------
   One global rule supplies the ring for every interactive element. Verify a
   new control by tabbing to it: .focus() from JS does not match
   :focus-visible. */
:focus-visible {
  outline: 2px solid var(--brand-ink);
  outline-offset: 3px;
  border-radius: var(--radius);
}

/* Sections get tabindex="-1" as a focus target after a tab switch. That ring
   would be meaningless noise, so suppress it — the rule is element-qualified
   and therefore outranks :focus-visible above. */
[tabindex='-1']:focus {
  outline: none;
}

/* ---------- SKIP LINK ---------- */
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.skip-link:focus {
  position: fixed;
  left: var(--s-4);
  top: var(--s-3);
  width: auto;
  height: auto;
  padding: var(--s-2) var(--s-3);
  background: var(--bg);
  color: var(--brand-ink);
  border: 1px solid var(--brand-ink);
  border-radius: var(--radius);
  z-index: 100;
}

/* ---------- LAYOUT ---------- */
.container {
  max-width: var(--measure);
  margin: 0 auto;
  padding: 0 var(--s-4);
}

/* Tabbed sections: hidden by default, one at a time.
   The hiding is scoped to `.js`, which the inline <head> script sets the moment
   it parses. Without that scope a blocked or failed script.js would leave
   `section { display: none }` with no `.active` to reveal it: an empty page.
   Under `.no-js` nothing is hidden, every section stacks, and the nav degrades
   into ordinary in-page anchors that the browser handles on its own.

   The hero opts out of both rules with `:not(.hero)` and is handled by its own
   pair further down. It has to: `.js section.active` weighs 0-2-1 and would
   outrank a plain `.hero.active` at 0-2-0, silently flattening the two-column
   hero to a single block the first time a tab switch ran. */
.js section:not(.hero) {
  display: none;
}

.js section:not(.hero).active {
  display: block;
}

.section {
  padding-block: var(--s-6);
  scroll-margin-top: 76px;
}

/* ---------- SECTION HEAD ----------
   One shared header row so the six tabs share a rhythm by construction rather
   than by six hand-matched rules. */
.section-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-5);
  padding-bottom: var(--s-3);
  margin-bottom: var(--s-6);
  border-bottom: 1px solid var(--rule);
}

.section-title {
  position: relative;
  font-size: var(--t-h2);
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -0.015em;
}

/* The single accent mark per section: a 16x2 rule, not the 4px gradient bar
   that used to sit beside all five h2s. */
.section-title::before {
  content: "";
  position: absolute;
  left: 0;
  top: -0.85em;
  width: 16px;
  height: 2px;
  background: var(--brand-ink);
}

.section-note {
  font-size: var(--t-meta);
  line-height: 1.6;
  color: var(--ink-3);
  max-width: 36ch;
  text-align: right;
}

/* ---------- NAVBAR ----------
   Floating bar, not full-bleed: the sticky wrapper stays transparent while
   .nav-container carries the background, the all-around hairline, and the
   single radius. It sticks 8px below the viewport top, so content slides
   beneath through the gap. Flat by construction — no pill, no shadow, no
   blur on the bar itself. */
.nav {
  position: sticky;
  top: var(--s-2);
  z-index: 50;
  padding: var(--s-2) var(--s-4) 0;
}

.nav-container {
  max-width: var(--measure);
  margin: 0 auto;
  padding: 6px var(--s-4);
  min-height: 57px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
}

.brand {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  min-width: 0;
  flex: 1 1 auto;
  text-decoration: none;
  color: var(--ink);
}

.brand-logo {
  width: 26px;
  height: auto;
  flex: none;
  border-radius: var(--radius);
}

/* Two spans, not one gradient. The literal space between them is deliberate:
   it is what makes the accessible name read "Muhammad Almuwarisin" instead of
   "MuhammadAlmuwarisin". */
.nav-name {
  font-size: var(--t-sm);
  font-weight: 600;
  letter-spacing: -0.01em;
  white-space: nowrap;
}

.nav-first {
  color: var(--ink);
}

.nav-last {
  color: var(--brand-ink);
}

.nav-right {
  display: flex;
  align-items: center;
  gap: var(--s-1);
}

.nav-links,
.js .nav-inline {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  margin-right: var(--s-2);
}

/* The twin row exists only with JS (it is the visible nav on desktop); without
   script.js the plain .nav-links row above stands, so this stays hidden. */
.nav-inline {
  display: none;
}

.nav-links a,
.js .nav-inline a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  font-size: var(--t-meta);
  font-weight: 500;
  color: var(--ink-2);
  text-decoration: underline;
  text-decoration-color: transparent;
  text-decoration-thickness: 2px;
  text-underline-offset: 6px;
  transition: color var(--t) cubic-bezier(0.2, 0, 0, 1),
    text-decoration-color var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.nav-links a:hover,
.js .nav-inline a:hover {
  color: var(--ink);
  text-decoration-color: var(--brand-ink);
}

.nav-links a[aria-current='page'],
.js .nav-inline a[aria-current='page'] {
  color: var(--ink);
  font-weight: 600;
  text-decoration-color: var(--brand-ink);
}

/* Icon buttons: 44px hit area, no border and no fill so they read as part of
   the bar rather than as stickers on it. */
.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  flex: none;
  border-radius: var(--radius);
  color: var(--ink-2);
  transition: background var(--t) cubic-bezier(0.2, 0, 0, 1),
    color var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.icon-btn:hover {
  background: var(--bg-sunken);
  color: var(--ink);
}

.icon-btn svg {
  width: 20px;
  height: 20px;
}

/* Icon swap is pure CSS on the theme attribute, so script.js never has to
   touch the two SVGs. Light is the absence of [data-theme]. */
.theme-toggle .icon-sun {
  display: none;
}

[data-theme='dark'] .theme-toggle .icon-sun {
  display: block;
}

[data-theme='dark'] .theme-toggle .icon-moon {
  display: none;
}

/* Both icon buttons are inert without script.js, so neither is shown in the
   no-JS state — a control that looks live and does nothing is worse than an
   honest, quieter navbar. */
.no-js .theme-toggle,
.no-js .nav-toggle {
  display: none;
}

/* Three bars, not two, and no gradient. aria-expanded is the single source of
truth for the open state. */
.nav-toggle {
  display: none;
  flex-direction: column;
  gap: 5px;
}

.nav-toggle .bar {
  display: block;
  width: 18px;
  height: 1.5px;
  background: currentColor;
  transition: transform var(--t) cubic-bezier(0.2, 0, 0, 1),
    opacity var(--t) cubic-bezier(0.2, 0, 0, 1);
}

/* Spin-to-X, adapted from Hamburgers by Jonathan Suh (MIT): the box spins a
   half turn while the outer bars collapse into diagonals and the middle bar
   fades. The end state is a symmetric X, so the journey carries the motion
   and the rest state stays calm. Signature jonsuh easings and timings kept
   verbatim (220ms); the reduced-motion floor still zeroes them. */
.nav-toggle {
  transition: transform 220ms cubic-bezier(0.55, 0.055, 0.675, 0.19);
}

.nav-toggle[aria-expanded='true'] {
  transform: rotate(180deg);
  transition: transform 220ms cubic-bezier(0.215, 0.61, 0.355, 1) 120ms;
}

.nav-toggle[aria-expanded='true'] .bar:first-child {
  transform: translateY(6.5px) rotate(45deg);
}

.nav-toggle[aria-expanded='true'] .bar:nth-child(2) {
  opacity: 0;
}

.nav-toggle[aria-expanded='true'] .bar:last-child {
  transform: translateY(-6.5px) rotate(-45deg);
}

/* Global drawer (desktop included): the hamburger is always visible, and the
   inline twin row beside it is the desktop face of the same five links. A
   fixed left panel under the sticky bar (z-index 40 vs 50: the hamburger
   stays clickable, top padding only clears the bar visually, no hardcoded
   `top`). Closed means translated away AND visibility-hidden with a delayed
   visibility transition, so keyboard users never tab into links they cannot
   see; verify by tabbing. `aria-expanded` is the only open-state signal, and
   JS keeps aria-current in sync on both twins. Everything drawer-shaped is
   `.js`-scoped: without script.js the plain row stands. */
.js .nav-toggle {
  display: inline-flex;
}

.js .nav-links {
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  z-index: 40;
  width: min(300px, 84vw);
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0;
  margin: 0;
  padding: 76px var(--s-3) var(--s-4);
  overflow-y: auto;
  background: var(--bg);
  border-right: 1px solid var(--rule);
  transform: translateX(-102%);
  visibility: hidden;
  transition: transform var(--t) cubic-bezier(0.2, 0, 0, 1),
    visibility 0s var(--t);
}

.js .nav-links.open {
  transform: none;
  visibility: visible;
  transition: transform var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.js .nav-links a {
  min-height: 44px;
  /* The 2px left border is reserved on every item (transparent at rest) so
     marking the active tab cannot shift its own label sideways. It is a
     hairline rule, not an inset box-shadow, to keep the flat surface. */
  padding-inline: calc(var(--s-1) + 2px) var(--s-1);
  border-top: 1px solid var(--rule);
  border-left: 2px solid transparent;
  text-decoration: none;
  font-size: var(--t-sm);
}

.js .nav-links a[aria-current='page'] {
  border-left-color: var(--brand-ink);
  color: var(--ink);
  font-weight: 600;
}

/* The drawer is mobile-only: on desktop the links stand in the bar as a
   plain row (below), and the hamburger stays hidden. Everything
   drawer-shaped is `.js`-scoped: without script.js the row and the 768px
   static fallback stand. */

/* Blur scrim: a fixed wash over the page while the drawer is open, so focus
   visibly moves to navigation. Driven by the body.nav-open flag setMenuOpen
   already maintains — this is what gives that previously unused class its
   job. Below bar and panel (z-index 30), aria-hidden, and a plain click on it
   falls through to the existing outside-click handler, which closes the
   drawer. Opacity fades at --t; reduced-motion makes it instant. */
/* Inert by default (including no-JS, where it is an empty static div); only
   the .js rules below give it paint. */
.nav-scrim {
  visibility: hidden;
}

.js .nav-scrim {
  position: fixed;
  inset: 0;
  z-index: 30;
  background: var(--scrim);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--t) cubic-bezier(0.2, 0, 0, 1),
    visibility 0s var(--t);
}

.js body.nav-open .nav-scrim {
  opacity: 1;
  visibility: visible;
  transition: opacity var(--t) cubic-bezier(0.2, 0, 0, 1);
}

/* ---------- BUTTONS ---------- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  min-height: 44px;
  padding: 0 var(--s-3);
  border: 1px solid transparent;
  border-radius: var(--radius);
  font-size: var(--t-sm);
  font-weight: 500;
  line-height: 1.2;
  text-decoration: none;
  transition: background var(--t) cubic-bezier(0.2, 0, 0, 1),
    color var(--t) cubic-bezier(0.2, 0, 0, 1),
    border-color var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.btn--solid {
  background: var(--brand);
  border-color: var(--brand);
  color: #ffffff;
}

.btn--solid:hover {
  background: var(--brand-hover);
  border-color: var(--brand-hover);
}

.btn--outline {
  background: transparent;
  border-color: var(--rule-2);
  color: var(--ink);
}

.btn--outline:hover {
  background: var(--bg-sunken);
  border-color: var(--ink-3);
}

/* Text link styled as a button. It keeps the 44px row height of its siblings so
   the whole action group shares one target size; border:0 must come before
   border-bottom. */
.btn--link {
  min-height: 44px;
  padding: 0;
  border: 0;
  border-bottom: 1px solid currentColor;
  border-radius: 0;
  background: none;
  color: var(--brand-ink);
}

.btn--link:hover {
  color: var(--ink);
}

/* External-link nudge, projects only: a small arrow that shifts on hover and
   keyboard focus, echoing the contact-row arrow in the same --t language. */
.project-actions .btn--link::after {
  content: "\2197";
  margin-left: var(--s-1);
  font-size: var(--t-meta);
  transition: transform var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.project-actions .btn--link:hover::after,
.project-actions .btn--link:focus-visible::after {
  transform: translate(2px, -2px);
}

/* Inline link inside prose. */
.link {
  color: var(--brand-ink);
  text-decoration: none;
  border-bottom: 1px solid var(--rule-2);
  transition: color var(--t) cubic-bezier(0.2, 0, 0, 1),
    border-color var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.link:hover {
  border-bottom-color: var(--brand-ink);
}

/* ---------- HERO ---------- */
.hero {
  display: grid;
  grid-template-columns: minmax(0, 1.4fr) minmax(280px, 340px);
  gap: var(--s-6);
  /* Top-aligning gives both columns one shared entry line so the eyebrow and
     portrait frame start cleanly together. */
  align-items: start;
  padding-block: var(--s-6);
  scroll-margin-top: 76px;
}

/* Same `.js` scoping as the tab rule above: hidden only when script.js is there
   to reveal it again. `.hero.active` is declared after this on purpose so the
   active state wins over the hidden state at equal specificity. */
.js .hero {
  display: none;
}

.js .hero.active {
  display: grid;
}

.hero-intro {
  max-width: 54ch;
}

.hero h1 {
  margin-top: var(--s-2);
  font-size: var(--t-display);
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.025em;
  color: var(--ink);
  text-wrap: balance;
}

.hero-desc {
  margin-top: var(--s-4);
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.hero-lede {
  font-size: var(--t-lede);
  font-weight: 500;
  line-height: 1.5;
  color: var(--ink);
  text-wrap: pretty;
}

.hero-stack {
  margin-top: var(--s-3);
  font-size: var(--t-mono);
  letter-spacing: 0.04em;
  color: var(--ink-3);
}

.hero-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s-3);
  margin-top: var(--s-5);
}

.hero-figure {
  margin: 0;
  max-width: 340px;
  width: 100%;
  justify-self: end;
}

.hero-figure img {
  width: 100%;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  object-position: var(--portrait-focus);
  border-radius: var(--radius);
  border: 1px solid var(--rule);
}

.hero-figure figcaption {
  margin-top: var(--s-2);
  font-size: var(--t-mono);
  letter-spacing: 0.02em;
  color: var(--ink-3);
}

/* ---------- HERO: ENTRANCE ----------
   One coordinated sequence, played once. JS adds .hero-anim (which stages the
   hidden states) and then .is-in on a double rAF. If script.js never runs,
   .hero-anim is never added and everything is visible — no stuck opacity: 0.
   Hidden states are opacity + transform only, so staging never shifts layout.
   Order: eyebrow, name line 1, name line 2, lede, stack, figure, actions,
   status. The name gets a masked rise (two explicit lines, never typed). */
.hero-name {
  display: flex;
  flex-direction: column;
}

.hero-line {
  display: block;
  overflow: hidden;
}

.hero-line > span {
  display: block;
}

/* First name in brand-ink: echoes the purple surname in the navbar and gives
   the two-line assembly its own rhythm (purple settles first, ink follows).
   AA-safe at this size in both themes (7.10:1 light, 7.44:1 dark). */
.hero-line:first-child > span {
  color: var(--brand-ink);
}

.js .hero.hero-anim .eyebrow,
.js .hero.hero-anim .hero-line > span,
.js .hero.hero-anim .hero-lede,
.js .hero.hero-anim .hero-stack,
.js .hero.hero-anim .hero-actions,
.js .hero.hero-anim .hero-figure {
  transition: opacity var(--t-enter), transform var(--t-enter);
}

.js .hero.hero-anim:not(.is-in) .eyebrow,
.js .hero.hero-anim:not(.is-in) .hero-lede,
.js .hero.hero-anim:not(.is-in) .hero-stack,
.js .hero.hero-anim:not(.is-in) .hero-actions {
  opacity: 0;
  transform: translateY(14px);
}

.js .hero.hero-anim:not(.is-in) .hero-line > span {
  transform: translateY(110%);
}

.js .hero.hero-anim:not(.is-in) .hero-figure {
  opacity: 0;
  transform: translateY(28px);
}

.js .hero.hero-anim .eyebrow { transition-delay: 0ms; }
.js .hero.hero-anim .hero-line:first-child > span { transition-delay: 90ms; }
.js .hero.hero-anim .hero-line:last-child > span { transition-delay: 180ms; }
.js .hero.hero-anim .hero-lede { transition-delay: 300ms; }
.js .hero.hero-anim .hero-stack { transition-delay: 390ms; }
.js .hero.hero-anim .hero-figure { transition-delay: 350ms; }
.js .hero.hero-anim .hero-actions { transition-delay: 470ms; }


/* ---------- ABOUT: TERMINAL ---------- */
/* Core identity element of the page: a real <dl>, so every line is semantic
   content first and an animation second. JS only adds .is-prep to type it in;
   without JS, reduced motion, or an IntersectionObserver, the full text is
   already in the DOM and visible. */
.term {
  max-width: 68ch;
  margin-bottom: var(--s-6);
  background: var(--bg-sunken);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  overflow: hidden;
}

.term-bar {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-2) var(--s-3);
  border-bottom: 1px solid var(--rule);
}

.term-dots {
  display: inline-flex;
  gap: 6px;
}

.term-dots i {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--rule-2);
}

.term-path {
  font-family: var(--mono);
  font-size: var(--t-mono);
  color: var(--ink-3);
  letter-spacing: 0.02em;
}

/* Bottom status bar mirroring the top chrome: branch, encoding, locale — all
   three true of this repository (main branch, BOM-less UTF-8, lang="id").
   Decorative window dressing, hidden from assistive technology. */
.term-foot {
  display: flex;
  gap: var(--s-4);
  padding: var(--s-2) var(--s-4);
  border-top: 1px solid var(--rule);
  font-family: var(--mono);
  font-size: var(--t-mono);
  letter-spacing: 0.02em;
  color: var(--ink-3);
}

.term-body {
  margin: 0;
  padding: var(--s-4);
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  font-family: var(--mono);
  font-size: var(--t-sm);
  line-height: 1.7;
  /* Line numbers 01–09 in the project-index voice: generated, so the typing
     script (which rewrites .term-cmd text) can never disturb them. */
  counter-reset: term;
}

.term-line {
  margin: 0;
}

.term-line dt {
  display: flex;
  gap: var(--s-2);
  color: var(--ink);
  font-weight: 500;
}

.term-line dt::before {
  counter-increment: term;
  content: counter(term, decimal-leading-zero);
  color: var(--ink-3);
  user-select: none;
}

/* Scanning aid: the command under the pointer lights up in the same purple
   the prompt already wears — no new accent, just feedback. */
.term-line:hover .term-cmd {
  color: var(--brand-ink);
}

.term-cmd {
  transition: color var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.term-prompt {
  color: var(--brand-ink);
  user-select: none;
}

.term-line dd {
  margin: 0 0 0 calc(var(--s-3) + 1ch);
  color: var(--ink-2);
}

/* Typed state: lines are staged but present for screen readers (opacity, not
   display or visibility). Removed the moment typing finishes. */
.term.is-prep .term-line {
  opacity: 0;
}

.term.is-prep .term-line.is-typed {
  opacity: 1;
  transition: opacity 100ms linear;
}

/* The output line stays hidden until its command has finished typing. */
.term.is-prep .term-line.is-typed dd {
  opacity: 0;
}

.term.is-prep .term-line.is-done dd {
  opacity: 1;
  transition: opacity 160ms linear;
}

.term-caret {
  display: inline-block;
  width: 0.6ch;
  height: 1.1em;
  margin-left: 2px;
  background: var(--brand-ink);
  vertical-align: text-bottom;
  animation: term-blink 1.1s steps(1) infinite;
}

@keyframes term-blink {
  50% { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .term-caret { animation: none; }
}

/* ---------- ABOUT: LAYOUT + SIDE FACTS ----------
   Terminal left, quick facts right. Same minmax(0, ...) discipline as every
   grid on this page: no track may let its content widen the document. The
   facts repeat only claims the terminal already states (location from the
   hero caption, the rest from terminal lines) — a second voice, not new
   content. Static markup, so no-JS and reduced motion get it for free. */
.about-layout {
  display: grid;
  grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
  gap: var(--s-6);
  align-items: start;
}

.about-layout .term {
  margin-bottom: 0;
}

.side-facts > div {
  padding-block: var(--s-3);
  border-top: 1px solid var(--rule);
}

.side-facts > div:last-child {
  border-bottom: 1px solid var(--rule);
}

.side-facts dt {
  font-family: var(--mono);
  font-size: var(--t-mono);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.side-facts dd {
  margin: 2px 0 0;
  font-size: var(--t-sm);
  line-height: 1.6;
  color: var(--ink);
}

/* Board rows enter staggered through the same shared observer as projects
   and contact; same once-only contract, same reduced-motion guards. */
.js .side-facts > div.is-prep:not(.is-in) {
  opacity: 0;
  transform: translateY(12px);
}

.js .side-facts > div.is-prep {
  transition: opacity var(--t-enter), transform var(--t-enter);
}

.js .side-facts > div:nth-child(2).is-prep { transition-delay: 70ms; }
.js .side-facts > div:nth-child(3).is-prep { transition-delay: 140ms; }

/* ---------- ABOUT ----------
   The old prose/fact-row system is gone with the Irisan A rewrite (terminal +
   status board); what survives is the shared fact label, still used by the
   education tab. */
.fact-label {
  font-size: var(--t-mono);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: var(--s-3);
}

/* ---------- SKILLS ----------
   The static label/value rows are gone (replaced by the ticker below); the
   inline tech list survives because education still uses it. */
.tech-inline {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-1) var(--s-2);
}

.tech-inline li {
  font-size: var(--t-mono);
  letter-spacing: 0.02em;
  color: var(--ink-3);
  white-space: nowrap;
}

.tech-inline li + li::before {
  content: "\00b7";
  margin-right: var(--s-2);
  color: var(--rule-2);
}

/* ---------- SKILLS: TICKER ----------
   Continuous loop, CSS-driven (no per-frame JS). The track holds six identical
   sets and translates 0 → -50%, so the wrap point is invisible by
   construction. Only the first set is exposed: screen readers meet each stack
   item exactly once. Motion pauses on hover and keyboard focus; reduced motion
   stops it entirely. There is no JS for this component. */
.ticker {
  overflow: hidden;
  border-block: 1px solid var(--rule);
}

.ticker-track {
  display: flex;
  width: max-content;
}

/* Motion is a .js enhancement: without script.js the track stays a static
   row (see the .no-js fallback below). Hover and keyboard focus pause the
   loop while engaged; reduced motion stops it entirely. */
.js .ticker-track {
  animation: ticker-scroll 26s linear infinite;
}

.ticker:hover .ticker-track,
.ticker:focus-within .ticker-track {
  animation-play-state: paused;
}

.ticker-group {
  display: flex;
  align-items: center;
  flex: none;
}

.ticker-item {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-3) var(--s-4);
  font-family: var(--mono);
  font-size: var(--t-sm);
  letter-spacing: 0.06em;
  color: var(--ink);
  white-space: nowrap;
}

.ticker-sep {
  color: var(--rule-2);
  user-select: none;
}

/* Brand marks, 18px like the old contact icons: large enough to read, small
   enough to stay metadata. Official Simple Icons colours (CC0), lifted where
   the official value falls apart on a theme background; the SVG inherits its
   fill from `color`, so no colour is ever hardcoded on the element itself.
   Icons are decorative (aria-hidden): the adjacent word carries the meaning,
   which is why icon contrast is exempt from the text-contrast budget. */
.ticker-defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.ticker-logo {
  width: 18px;
  height: 18px;
  flex: none;
  fill: currentColor;
  color: var(--mark);
}

.ticker-logo--html { --mark: #e34f26; }
.ticker-logo--css { --mark: #1572b6; }
.ticker-logo--js { --mark: #8a6d00; }
.ticker-logo--php { --mark: #777bb4; }
.ticker-logo--laravel { --mark: #ff2d20; }
.ticker-logo--blade { --mark: var(--brand-ink); }
.ticker-logo--tailwind { --mark: #0e7490; }
.ticker-logo--sqlite { --mark: #003b57; }

[data-theme='dark'] .ticker-logo--js { --mark: #f7df1e; }
[data-theme='dark'] .ticker-logo--tailwind { --mark: #06b6d4; }
[data-theme='dark'] .ticker-logo--php { --mark: #a9adda; }
[data-theme='dark'] .ticker-logo--sqlite { --mark: #4fb6d6; }

@keyframes ticker-scroll {
  to { transform: translateX(-50%); }
}

.no-js .ticker-track {
  width: auto;
  flex-wrap: wrap;
}

.no-js .ticker-group[aria-hidden='true'] {
  display: none;
}

/* ---------- PROJECTS ----------
   Editorial, not a dashboard. The flagship breaks the row pattern on purpose:
   its figure takes the full text-column width on top while the copy stacks
   beneath it. The second project stays a compact side row. One showcase, one
   entry — never two identical rows, never a card grid. */
.project {
  display: grid;
  grid-template-columns: 4ch minmax(0, 1.2fr) minmax(0, 1fr);
  gap: var(--s-3) var(--s-5);
  align-items: start;
  padding-block: var(--s-5);
  border-top: 1px solid var(--rule);
}

.project + .project {
  margin-top: var(--s-6);
}

.project--compact {
  grid-template-columns: 4ch minmax(0, 1fr) 320px;
}

.project--featured {
  grid-template-columns: 4ch minmax(0, 1fr);
}

.project--featured .project-media {
  grid-column: 2;
  grid-row: 1;
  /* Showcase presence without the stretch: full-width framing past ~720px
     reads empty rather than grand, so the flagship caps here while the copy
     below keeps its measure. */
  max-width: 720px;
}

.project--featured .project-main {
  grid-column: 2;
  grid-row: 2;
  max-width: 68ch;
}

.project--featured .project-index {
  grid-row: 1 / span 2;
}

.project-index {
  font-size: var(--t-mono);
  letter-spacing: 0.06em;
  color: var(--brand-ink);
  line-height: 1;
  padding-top: 8px;
}

.project-title {
  font-size: var(--t-project);
  font-weight: 600;
  line-height: 1.25;
  letter-spacing: -0.02em;
}

.project--featured .project-title {
  font-size: var(--t-project-lg);
}

.project-lede {
  margin-top: var(--s-2);
  max-width: 54ch;
  font-size: var(--t-sm);
  line-height: 1.7;
  color: var(--ink-2);
  text-wrap: pretty;
}

.project-capabilities {
  margin-top: var(--s-4);
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.project-capabilities li {
  position: relative;
  padding-left: var(--s-3);
  max-width: 58ch;
  font-size: var(--t-meta);
  line-height: 1.6;
  color: var(--ink-3);
}

/* Em dash instead of the old purple dot: same left alignment, one less accent. */
.project-capabilities li::before {
  content: "\2014";
  position: absolute;
  left: 0;
  color: var(--rule-2);
}

.project-meta {
  margin-top: var(--s-4);
}

/* Ruled label/value rows in the site's fact-row language, replacing the old
   wrapped cluster: each fact gets its own hairline, so Peran/Stack/Status
   scan like the contact rows instead of pooling into a grey paragraph. */
.project-meta > div {
  display: grid;
  grid-template-columns: 12ch minmax(0, 1fr);
  gap: var(--s-3);
  align-items: baseline;
  padding-block: var(--s-2);
  border-top: 1px solid var(--rule);
}

.project-meta > div:last-child {
  border-bottom: 1px solid var(--rule);
}

.project-meta dt {
  font-size: var(--t-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: 2px;
}

.project-meta dd {
  margin: 0;
  font-size: var(--t-meta);
  line-height: 1.5;
  color: var(--ink-2);
}

/* "Solo project" carries the claim in ink weight, not in brand colour, so the
   six-place purple budget stays intact. */
.project-meta dd.claim {
  color: var(--ink);
  font-weight: 600;
}

.project-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
  margin-top: var(--s-4);
  padding-top: var(--s-4);
  border-top: 1px solid var(--rule);
}

/* The frame plus its caption travel as one media block: the reveal stages
   `.project-media`, the hover zoom stays on the image inside. */
.project-media {
  grid-column: 3;
  margin: 0;
  transition: opacity var(--t-enter), transform var(--t-enter);
}

/* Fixed 16:9 frame with contain, so a wider screenshot is letterboxed rather
   than cropped. */
.project-figure {
  display: block;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  background: var(--bg-sunken);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  transition: border-color var(--t) cubic-bezier(0.2, 0, 0, 1),
    opacity var(--t-enter),
    transform var(--t-enter);
}

.project-figure:hover {
  border-color: var(--rule-2);
}

.project-figure img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  transition: transform var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.project-figure:hover img,
.project-figure:focus-visible img {
  transform: scale(1.03);
}

/* ---------- PROJECTS: REVEAL ----------
   One-time entrance for the media block and its index number; the text column
   is always readable immediately. JS adds .is-prep at init (only with JS, motion
   allowed, and IntersectionObserver available) and .is-in on first intersect,
   then unobserves — never replayed on scroll. Opacity + transform only, so
   staging cannot shift layout; the fixed aspect-ratio frame holds the box. */
.js .project-media.is-prep:not(.is-in),
.js .project-index.is-prep:not(.is-in) {
  opacity: 0;
  transform: translateY(16px);
}

.js .project-index.is-prep {
  transition: opacity var(--t-enter), transform var(--t-enter);
  transition-delay: 80ms;
}

.projects-note {
  margin-top: var(--s-5);
  padding-top: var(--s-4);
  border-top: 1px solid var(--rule);
  max-width: 68ch;
  font-size: var(--t-meta);
  line-height: 1.65;
  color: var(--ink-3);
}

/* ---------- ARTICLES ----------
   One verified publication. Title first at full width (unlike the short
   academic names, a 100-character title cannot live in a 20ch column), then
   a description/side split reusing the academic rows. Nothing here is
   invented: no abstract, no DOI, no metrics — only venue facts. */
.article-list > li {
  padding-block: var(--s-4);
  border-top: 1px solid var(--rule);
}

.article-list > li:last-child {
  border-bottom: 1px solid var(--rule);
}

.article-title {
  margin-top: var(--s-2);
  font-size: var(--t-project-lg);
  font-weight: 600;
  letter-spacing: -0.01em;
  line-height: 1.3;
  max-width: 62ch;
  text-wrap: balance;
}

.article-venue {
  font-family: var(--mono);
  font-size: var(--t-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.article-authors {
  margin-top: var(--s-3);
  font-size: var(--t-sm);
  color: var(--ink-2);
}

.article-authors strong {
  color: var(--ink);
}

.article-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3);
  margin-top: var(--s-4);
  padding-top: var(--s-4);
  border-top: 1px solid var(--rule);
}

.article-body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--s-2) var(--s-4);
  align-items: baseline;
  margin-top: var(--s-3);
}

/* ---------- EDUCATION ---------- */
.edu-row {
  display: grid;
  grid-template-columns: 22ch minmax(0, 1fr) auto;
  gap: var(--s-2) var(--s-4);
  align-items: baseline;
  padding-block: var(--s-4);
  border-top: 1px solid var(--rule);
}

.edu-row h3 {
  font-size: var(--t-h3);
  font-weight: 600;
}

.edu-row p {
  font-size: var(--t-sm);
  color: var(--ink-2);
}

.edu-meta {
  font-size: var(--t-mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-3);
  text-align: right;
}

.edu-projects {
  margin-top: var(--s-6);
}

.edu-note {
  max-width: 66ch;
  margin-top: var(--s-3);
  font-size: var(--t-meta);
  line-height: 1.65;
  color: var(--ink-3);
}

.academic-list {
  margin-top: var(--s-5);
}

.academic-list > li {
  display: grid;
  grid-template-columns: 20ch minmax(0, 1fr) 16ch;
  gap: var(--s-2) var(--s-4);
  align-items: baseline;
  padding-block: var(--s-4);
  border-top: 1px solid var(--rule);
}

.academic-list > li:last-child {
  border-bottom: 1px solid var(--rule);
}

.academic-list h4 {
  font-size: var(--t-h3);
  font-weight: 600;
}

.academic-desc {
  max-width: 58ch;
  font-size: var(--t-sm);
  line-height: 1.65;
  color: var(--ink-2);
  text-wrap: pretty;
}

/* Identifiers are content, not prose. No box: a tinted background would drag
   --brand-ink below AA, and mono + ink already sets them apart. */
.academic-desc code {
  font-family: var(--mono);
  /* Scale step, not 0.9em: inside 15px text that resolved to an off-scale
     13.5px. */
  font-size: var(--t-meta);
  color: var(--ink);
}

.academic-side {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--s-2);
  text-align: right;
}

.academic-link {
  font-size: var(--t-meta);
  color: var(--ink-2);
  text-decoration: none;
  border-bottom: 1px solid var(--rule-2);
  transition: color var(--t) cubic-bezier(0.2, 0, 0, 1),
    border-color var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.academic-link:hover {
  color: var(--brand-ink);
  border-bottom-color: var(--brand-ink);
}

/* ---------- CONTACT ----------
   Numbered document rows: index + icon + label, a readable value, and an
   arrow. Hover/focus answers with three quiet signals at once — wash, accent
   rule, travelling arrow — all in the existing --t language. */
.contact-row {
  position: relative;
  display: grid;
  grid-template-columns: 20ch minmax(0, 1fr) 2ch;
  gap: var(--s-3);
  align-items: center;
  padding: var(--s-4) 0 var(--s-4) var(--s-3);
  border-top: 1px solid var(--rule);
  text-decoration: none;
  transition: background var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.contact-row:hover,
.contact-row:focus-visible {
  background: var(--bg-sunken);
}

.contact-list > li:last-child .contact-row {
  border-bottom: 1px solid var(--rule);
}

/* The one hover accent on the row: a 2px rule that scales in from the centre
   inside the row's own left gutter, so it can never cause overflow. */
.contact-row::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--brand-ink);
  transform: scaleY(0);
  transform-origin: center;
  transition: transform var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.contact-row:hover::before,
.contact-row:focus-visible::before {
  transform: scaleY(1);
}

.contact-row:hover .contact-value,
.contact-row:focus-visible .contact-value {
  color: var(--brand-ink);
}

.contact-label {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--t-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  transition: color var(--t) cubic-bezier(0.2, 0, 0, 1);
}

/* 16px, not 14px: at 14px the GitHub and LinkedIn marks lost their internal
   detail and started reading as generic blobs. */
.contact-label svg {
  width: 18px;
  height: 18px;
  flex: none;
  fill: currentColor;
}

/* Brand colours per icon: GitHub and LinkedIn carry their official marks; the
   mail envelope is a generic glyph, so it stays currentColor and follows the
   label. Decorative like the ticker marks — the word beside each icon carries
   the meaning, which is why icon colour is exempt from the text budget. */
.contact-icon--github {
  color: #181717;
}

.contact-icon--linkedin {
  color: #0a66c2;
}

[data-theme='dark'] .contact-icon--github {
  color: #f2f2f4;
}

/* The label and its mark move together, so the icon never ends up one colour
   behind the word beside it. This is the row's existing hover reaction, not a
   second effect layered on top of it. */
.contact-row:hover .contact-label,
.contact-row:focus-visible .contact-label {
  color: var(--brand-ink);
}

/* Full URL, never an ellipsis: a visitor should be able to read and retype it.
   Long values wrap instead of overflowing. One scale step up from metadata so
   the value reads as the row's voice, not its footnote. */
.contact-value {
  font-size: var(--t-sm);
  letter-spacing: 0.01em;
  color: var(--ink);
  overflow-wrap: anywhere;
  transition: color var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.contact-arrow {
  grid-column: 3;
  justify-self: end;
  /* On the scale rather than 0.9em, which resolved to 15.3px. */
  font-size: var(--t-sm);
  line-height: 1;
  color: var(--ink-3);
  transition: transform var(--t) cubic-bezier(0.2, 0, 0, 1),
    color var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.contact-row:hover .contact-arrow,
.contact-row:focus-visible .contact-arrow {
  color: var(--ink);
  transform: translate(2px, -2px);
}

/* One-time entrance, same contract as the projects reveal: JS stages
   .is-prep, the shared observer releases .is-in per row with a short stagger,
   then unobserves. Text is never hidden without JS to reveal it. */
.js .contact-row.is-prep:not(.is-in) {
  opacity: 0;
  transform: translateY(12px);
}

.js .contact-row.is-prep {
  transition: opacity var(--t-enter), transform var(--t-enter);
}

.js .contact-list > li:nth-child(2) .contact-row.is-prep {
  transition-delay: 90ms;
}

.js .contact-list > li:nth-child(3) .contact-row.is-prep {
  transition-delay: 180ms;
}

.contact-outro {
  margin-top: var(--s-5);
  max-width: 54ch;
  font-size: var(--t-sm);
  line-height: 1.7;
  color: var(--ink-2);
}

/* ---------- FOOTER ---------- */
.footer {
  margin-top: var(--s-6);
  padding-block: var(--s-4) var(--s-5);
  border-top: 1px solid var(--rule);
}

.footer-inner {
  max-width: var(--measure);
  margin: 0 auto;
  padding: 0 var(--s-4);
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--s-2);
  font-size: var(--t-meta);
  color: var(--ink-3);
}

.footer-name {
  color: var(--ink-2);
  font-weight: 500;
}

.footer-links {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--s-3);
}

/* padding-block, not margin: the underline has to sit under the text while the
   target stays 28px tall, so the footer link clears the 24px minimum without
   opening up a gap between the links. */
.footer-links a {
  display: inline-block;
  padding-block: 6px;
  color: var(--ink-2);
  text-decoration: none;
  border-bottom: 1px solid var(--rule-2);
  transition: color var(--t) cubic-bezier(0.2, 0, 0, 1),
    border-color var(--t) cubic-bezier(0.2, 0, 0, 1);
}

.footer-links a:hover {
  color: var(--ink);
  border-bottom-color: var(--ink-3);
}

/* ---------- 404 ----------
   Standalone error page (GitHub Pages serves 404.html for unknown paths).
   Centered, static, no script.js: everything visible with or without JS. */
.notfound {
  max-width: var(--col);
  margin: 0 auto;
  padding-block: var(--s-7);
  text-align: center;
}

.notfound h1 {
  margin-top: var(--s-2);
  font-size: var(--t-display);
  font-weight: 600;
  letter-spacing: -0.025em;
  text-wrap: balance;
}

.notfound-lede {
  margin: var(--s-4) auto 0;
  max-width: 52ch;
  color: var(--ink-2);
  text-wrap: pretty;
}

.notfound-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--s-3);
  margin-top: var(--s-5);
}

/* ============================================================
   CASE STUDY (standalone page, not the tabbed index)
   ------------------------------------------------------------
   Scoped .cs-* and deliberately built from <div>, never <section>:
   the index hides every section with `.js section:not(.hero) { display: none }`
   and script.js strips .active from them on load, so <section> on a
   second page would collapse to nothing.
   ============================================================ */
.cs-back {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  /* 12px of padding carries this standalone link over the 24px minimum
     without turning it into a pill. */
  padding-block: 12px;
  margin-block: var(--s-5) var(--s-4);
  font-size: var(--t-meta);
  font-weight: 500;
  color: var(--ink-2);
  text-decoration: none;
}

.cs-back:hover {
  color: var(--brand-ink);
}

.cs-header {
  padding-bottom: var(--s-5);
  border-bottom: 1px solid var(--rule);
}

.cs-header h1 {
  font-size: var(--t-display);
  font-weight: 600;
  letter-spacing: -0.03em;
  line-height: 1.1;
  margin-top: var(--s-3);
}

.cs-lede {
  margin-top: var(--s-4);
  max-width: var(--col);
  font-size: var(--t-lede);
  line-height: 1.6;
  color: var(--ink-2);
}

.cs-meta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3) var(--s-5);
  margin-top: var(--s-5);
}

.cs-meta > li {
  min-width: 0;
}

.cs-meta-label {
  display: block;
  font-family: var(--mono);
  font-size: var(--t-mono);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: 2px;
}

.cs-meta-value {
  display: block;
  font-size: var(--t-sm);
  color: var(--ink);
}

.cs-nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  margin-top: var(--s-5);
}

.cs-block {
  padding-top: var(--s-6);
}

.cs-block h2 {
  position: relative;
  font-size: var(--t-h2);
  font-weight: 600;
  letter-spacing: -0.015em;
  line-height: 1.3;
  margin-bottom: var(--s-3);
}

.cs-block h2::before {
  content: "";
  position: absolute;
  left: 0;
  top: -0.85em;
  width: 16px;
  height: 2px;
  background: var(--brand-ink);
}

.cs-block h3 {
  font-size: var(--t-h3);
  font-weight: 600;
  margin-bottom: var(--s-1);
}

.cs-block > p {
  max-width: 68ch;
  font-size: var(--t-sm);
  line-height: 1.7;
  color: var(--ink-2);
}

.cs-block > p + p,
.cs-block > p + .cs-list,
.cs-block > p + .cs-flow,
.cs-block > p + .cs-split,
.cs-block > p + .cs-note {
  margin-top: var(--s-3);
}

.cs-block code {
  font-family: var(--mono);
  font-size: var(--t-meta);
  color: var(--ink);
}

/* Pipeline steps: numbered rows, not five boxes. */
.cs-flow {
  margin-top: var(--s-5);
  counter-reset: cs-step;
}

.cs-flow > li {
  display: grid;
  grid-template-columns: 3ch minmax(0, 1fr);
  gap: var(--s-3);
  padding-block: var(--s-4);
  border-top: 1px solid var(--rule);
  counter-increment: cs-step;
}

.cs-flow > li:last-child {
  border-bottom: 1px solid var(--rule);
}

.cs-flow > li::before {
  content: counter(cs-step, decimal-leading-zero);
  font-family: var(--mono);
  font-size: var(--t-mono);
  letter-spacing: 0.06em;
  color: var(--brand-ink);
  line-height: 1.8;
}

.cs-flow > li > h3 {
  margin-bottom: 2px;
}

.cs-flow p {
  max-width: 64ch;
  font-size: var(--t-sm);
  line-height: 1.6;
  color: var(--ink-2);
}

/* Technical choices: two-column definition rows. */
.cs-split {
  margin-top: var(--s-5);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 0 var(--s-6);
}

.cs-split > div {
  padding-block: var(--s-4);
  border-top: 1px solid var(--rule);
}

.cs-split p {
  max-width: 44ch;
  font-size: var(--t-sm);
  line-height: 1.65;
  color: var(--ink-2);
}

.cs-list {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  margin-top: var(--s-3);
}

.cs-list li {
  position: relative;
  padding-left: var(--s-3);
  max-width: 68ch;
  font-size: var(--t-sm);
  line-height: 1.65;
  color: var(--ink-2);
}

.cs-list li::before {
  content: "\2014";
  position: absolute;
  left: 0;
  color: var(--rule-2);
}

.cs-list strong {
  color: var(--ink);
}

/* "Yang tidak saya klaim" — the only boxed element on this page, because it
   genuinely is the one thing that must not be skimmed past. */
.cs-note {
  margin-top: var(--s-6);
  padding: var(--s-4) var(--s-4) var(--s-4) var(--s-5);
  border-left: 2px solid var(--brand-ink);
  background: var(--bg-sunken);
  border-radius: 0 var(--radius) var(--radius) 0;
}

.cs-note h3 {
  /* `.cs-note` is a SIBLING of `.cs-block`, not a child, so `.cs-block h3`
     never matched this heading and it inherited the UA default h3 size —
     1.17em of the 17px body step, i.e. an off-scale 19.89px. Only one heading
     on the site was doing this; the others sit inside `.cs-block`. */
  font-size: var(--t-h3);
  margin-bottom: var(--s-2);
}

.cs-note .cs-list {
  margin-top: 0;
}

.cs-next {
  margin-top: var(--s-6);
  padding-top: var(--s-5);
  border-top: 1px solid var(--rule);
}

/* ---------- RESPONSIVE ----------
   Re-derived from the content, not scaled down from the desktop grid. */
@media (max-width: 1024px) {
  .hero {
    grid-template-columns: minmax(0, 1fr) minmax(240px, 280px);
    gap: var(--s-5);
  }

  .hero-intro {
    max-width: 48ch;
  }

  .hero-figure {
    max-width: 280px;
  }

  .about-layout {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-5);
  }
}

@media (max-width: 900px) {
  .project,
  .project--compact {
    grid-template-columns: 4ch minmax(0, 1fr);
  }

  .project-media {
    grid-column: 2;
    margin-top: var(--s-2);
  }

  /* The compact project keeps a smaller screenshot when it drops below the
     three-column layout, otherwise it becomes as tall as the featured one. */
  .project--compact .project-media {
    max-width: 320px;
  }

  .edu-row {
    grid-template-columns: 20ch minmax(0, 1fr);
  }

  .edu-meta {
    grid-column: 2;
    text-align: left;
  }

  .academic-list > li {
    grid-template-columns: 18ch minmax(0, 1fr);
  }

  .academic-side {
    grid-column: 2;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: baseline;
    text-align: left;
  }
}

@media (max-width: 768px) {
.container {
    padding: 0 var(--s-3);
  }

  /* The footer carries its own gutter rather than inheriting .container, so it
     needs the same mobile step. Without this the footer sits 8px right of
     every section between 561px and 768px. */
  .footer-inner {
    padding-inline: var(--s-3);
  }

  .section {
    padding-block: var(--s-5);
  }

  .section-head {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--s-2);
  }

  .section-note {
    max-width: 100%;
    text-align: left;
  }

  /* Narrow bar: tighter gutters, brand takes the remaining row. The drawer
     itself is global now — only the bar compresses here. */
  .nav {
    padding: var(--s-2) var(--s-3) 0;
  }

  .nav-container {
    padding-inline: var(--s-3);
    gap: var(--s-2);
  }

  .nav-right {
    margin-left: auto;
  }

  /* Mobile drawer: the fixed left panel. It paints below the sticky bar
     (z-index 40 vs the bar's 50, so the hamburger stays clickable), and
     closed means translated away AND visibility-hidden with a delayed
     visibility transition, so keyboard users never tab into links they cannot
     see — verify by tabbing, not by reading CSS. `aria-expanded` on
     `.nav-toggle` is the only open-state signal; the class is derived from
     it. Under `.no-js` the wrapper dissolves and the links fall back to a
     static block (below). */
  /* One twin per breakpoint: the inline row hides on mobile, where only the
     drawer may be live. display:none removes it from tab order and the
     accessibility tree, no JS bookkeeping needed. */
  .js .nav-inline {
    display: none;
  }

  /* Without JS there is no toggle, so the links fall back to a static stacked
     block under the bar. Dissolving .nav-right re-parents them into the
     container flow: brand and theme share the first row, the links wrap below
     at full width. Column link styling is repeated here on purpose — the
     matching rules above are .js-scoped and must not leak into no-JS. */
  .no-js .nav-container {
    flex-wrap: wrap;
  }

  .no-js .nav-right {
    display: contents;
  }

  .no-js .nav-links {
    flex: 1 0 100%;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    margin: 0;
    padding: var(--s-2) 0 var(--s-3);
  }

  .no-js .nav-links a {
    min-height: 44px;
    padding-inline: calc(var(--s-1) + 2px) var(--s-1);
    border-top: 1px solid var(--rule);
    border-left: 2px solid transparent;
    text-decoration: none;
    font-size: var(--t-sm);
  }

  .hero {
    /* Top and bottom both step to --s-5 here, the same mobile step .section
       takes at this breakpoint. */
    grid-template-columns: 1fr;
    padding-block: var(--s-5);
    gap: var(--s-5);
  }

  .hero-intro {
    max-width: 100%;
  }

  .hero-figure {
    max-width: 260px;
    justify-self: start;
  }

  .project + .project {
    margin-top: var(--s-5);
  }

  .cs-block {
    padding-top: var(--s-5);
  }

  .cs-split {
    grid-template-columns: 1fr;
  }
}

@media (max-width: 560px) {
  .hero-figure {
    max-width: 200px;
  }

  .edu-row,
  .academic-list > li,
  .article-body {
    grid-template-columns: minmax(0, 1fr);
  }

  .edu-meta,
  .academic-side {
    grid-column: 1;
  }

  .project-actions .btn {
    flex: 1 1 auto;
  }

  /* The link variant draws a border-bottom, not a box. Growing it alongside its
     siblings drew a full-width rule under a five-character label: measured 251px
     of underline for a 49px "GitHub" at 360px, and it also pushed the three
     buttons onto a second row. Hold it at its label width. */
  .project-actions .btn--link {
    flex: 0 0 auto;
  }

  /* Label above value so the full LinkedIn URL gets a whole line of width.
     Two tracks, not one: an explicit grid-column: 3 would otherwise create an
     implicit third track and strand the arrow. */
  .contact-row {
    grid-template-columns: minmax(0, 1fr) 2ch;
  }

  .contact-label {
    grid-column: 1;
    grid-row: 1;
  }

  .contact-value {
    grid-column: 1;
    grid-row: 2;
  }

  .contact-arrow {
    grid-column: 2;
    grid-row: 1 / -1;
    align-self: center;
  }

  .footer-inner {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--s-2);
  }
}

/* ---------- MOBILE TEXT WRAPPING ----------
   Measured at 360/390: prose whose last line fell under a quarter of its own
   column, stranding a word or two on a line of its own. The eyebrow broke after
   two words (15% of the column); eleven prose blocks did the same between 9%
   and 24%. `pretty` asks the browser to pull a word down rather than strand
   one. `balance` is the right tool for the two-line eyebrow, where the goal is
   two even lines instead of a short tail. Both are pure typesetting: no
   element, colour, token, or desktop line break changes. */
@media (max-width: 768px) {
  /* Two-line labels, where the fault is uneven lines rather than a stranded
     tail. The eyebrow breaks after two words; balance evens the pair. */
  .eyebrow {
    text-wrap: balance;
  }

  /* Only the blocks that still broke badly. `.project-lede` and
     `.academic-desc` are absent on purpose: they already carry `pretty` at every
     width, so repeating it here would be a second source of truth for nothing. */
  .section-note,
  .edu-note,
  .projects-note,
  .project-meta dd,
  .contact-outro {
    text-wrap: pretty;
  }
}

/* ---------- REDUCED MOTION ----------
   Colour transitions and the focus ring stay; positional staging and hover
   nudges go. This block stays as a hard floor. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
  }

  /* Entrance and ticker guards: nothing may rest at opacity 0, and nothing
     decorative may move. The ticker falls back to a wrapped static row with
     the duplicate sets removed, so all eight skills stay readable. */
  .js .hero.hero-anim:not(.is-in) .eyebrow,
  .js .hero.hero-anim:not(.is-in) .hero-lede,
  .js .hero.hero-anim:not(.is-in) .hero-stack,
  .js .hero.hero-anim:not(.is-in) .hero-actions,
  .js .hero.hero-anim:not(.is-in) .hero-figure {
    opacity: 1;
    transform: none;
  }

  .js .hero.hero-anim:not(.is-in) .hero-line > span {
    transform: none;
  }

  .js .ticker-track {
    animation: none;
    width: auto;
    flex-wrap: wrap;
  }

  .ticker-group[aria-hidden='true'] {
    display: none;
  }

  /* Projects reveal never stages, and hover stops moving images and arrows.
     Colour and border feedback plus the focus ring are untouched. */
  .js .project-media.is-prep:not(.is-in),
  .js .project-index.is-prep:not(.is-in),
  .js .contact-row.is-prep:not(.is-in),
  .js .side-facts > div.is-prep:not(.is-in) {
    opacity: 1;
    transform: none;
  }

  .project-figure:hover img,
  .project-figure:focus-visible img,
  .project-actions .btn--link:hover::after,
  .project-actions .btn--link:focus-visible::after,
  .contact-row:hover .contact-arrow,
  .contact-row:focus-visible .contact-arrow {
    transform: none;
  }
}