/* src/styles/cursor-fallback.css */
/* Cursor safety net
   The design hides the OS cursor (cursor: none) and draws a custom dot/follower
   driven by designbyFA/script.js. The pointer must NEVER vanish while that
   custom cursor is not actually running. */

/* 1) Not (yet) driven by the script → show the OS cursor everywhere. */
html:not(.has-cursor),
html:not(.has-cursor) * {
  cursor: auto !important;
}

/* Keep the decorative dot/follower invisible until the script moves them. */
html:not(.has-cursor) #cursor,
html:not(.has-cursor) #cursor-follower {
  visibility: hidden !important;
  opacity: 0 !important;
}

/* 2) Touch / stylus / coarse pointers: there is no hover, so always keep the
      OS cursor (and never leave a stale dot sitting at 0,0). */
@media (pointer: coarse) {
  html,
  html * {
    cursor: auto !important;
  }
  #cursor,
  #cursor-follower {
    display: none !important;
  }
}


/* src/styles/form-status.css */
/* Contact form status line — live submit feedback
   ------------------------------------------------------------------
   The status line lives inside .contact-form-container, right after the form:

     <p class="form-status" role="status" aria-live="polite"></p>

   The form delivers to the Instatic CMS (data-instatic-form-mode="cms") and the
   runtime prints data-instatic-success-message into that element; if it stays
   silent after a send, src/scripts/form-status-reveal.js writes the form's own
   declared copy instead.

   STATES
     (empty)      → display:none, so the design keeps its resting rhythm
     .is-loading  → muted, pulsing "Sending…" while the runtime stores the row
     (any copy)   → one plain white note in the page's own type
     .is-reveal   → the entrance: a short rise + fade, restarted for every new
                    copy by src/scripts/form-status-reveal.js
     .is-error    → a quiet left rule, in case a state class ever appears

   Every rule here is an html-scoped ambient selector so it outranks the
   page-level .form-status rule, which is what hides this element. Without an
   explicit display on a rule that wins that cascade the message sits in the DOM
   and stays invisible — the reported "form terkirim, tapi teks tidak muncul".

   The copy is NEVER masked. The entrance animates the element itself, so a
   message can never be lost behind a wrapper that is waiting for a class.
   ------------------------------------------------------------------ */

html .contact-form-container .form-status {
  display: block;
  margin-top: var(--space-1-xs);
  max-width: 52ch;
  font-family: var(--font-primary);
  font-size: 0.85rem;
  line-height: 1.6;
  color: var(--text-secondary);
}

/* nothing to say yet → take up no space at all */
html .contact-form-container .form-status:empty {
  display: none;
}

/* received → a single white note, in the page's own type */
html .contact-form-container .form-status:not(:empty) {
  color: var(--text-white);
  font-weight: 500;
}

html .contact-form-container .form-status.is-success {
  color: var(--text-white);
  font-weight: 500;
}

/* while the runtime is storing the row */
@keyframes contactStatusPulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.45;
  }
}

html .contact-form-container .form-status.is-loading {
  color: var(--text-muted);
  animation: contactStatusPulse 1.4s ease-in-out infinite;
}

/* the entrance — the element animates, never a mask around its copy */
@keyframes contactStatusIn {
  from {
    opacity: 0;
    transform: translate3d(0, 8px, 0);
  }
  to {
    opacity: 1;
    transform: translate3d(0, 0, 0);
  }
}

html .contact-form-container .form-status.is-reveal:not(.is-loading) {
  animation: contactStatusIn 0.6s cubic-bezier(0.19, 1, 0.22, 1) both;
}

/* the failure line reads as a quiet note, not an alarm */
html .contact-form-container .form-status.is-error {
  border-left: 2px solid var(--accent-red);
  padding-left: var(--space-1-3xs);
  color: var(--text-secondary);
}

html .contact-form-container .form-status a {
  color: var(--accent-red);
  font-weight: 600;
  text-decoration-line: underline;
  text-underline-offset: 3px;
  transition-property: color;
  transition-duration: 0.3s;
  transition-timing-function: ease;
}

html .contact-form-container .form-status a:hover {
  color: var(--text-white);
}

html .contact-form-container .form-status a:focus-visible {
  outline: 2px solid var(--accent-red);
  outline-offset: 2px;
  border-radius: 2px;
}

/* inside the success note the underline is the only marker, so the whole
   message reads as one colour */
html .contact-form-container .form-status.is-success a {
  color: var(--text-white);
  font-weight: 500;
  text-decoration-thickness: 1px;
  text-underline-offset: 4px;
  text-decoration-color: color-mix(in srgb, var(--text-white) 50%, transparent);
}

html .contact-form-container .form-status.is-success a:hover {
  color: var(--text-white);
  text-decoration-color: var(--text-white);
}

@media (max-width: 768px) {
  html .contact-form-container .form-status {
    font-size: 0.8rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  html .contact-form-container .form-status.is-loading {
    animation: none;
  }

  /* no travel — the copy simply appears */
  html .contact-form-container .form-status.is-reveal:not(.is-loading) {
    animation: none;
  }

  html .contact-form-container .form-status a {
    transition-duration: 0.01ms;
  }
}


/* src/styles/work-card-mobile.css */
/* Work cards — mobile & tablet (≤768px)
   ------------------------------------------------------------------
   On phone/tablet the generated site screenshot is far too busy to carry
   text on top of it, so the card stacks instead:
       image (16/10) on top  →  solid text block underneath
   Scoped to .work-card-mobile, which every one of the 8 work cards carries,
   so the desktop layout (text over the image) is untouched.
   ------------------------------------------------------------------ */

@media (max-width: 768px) {
  /* card is no longer a fixed-ratio frame — it grows with its stacked content */
  html .work-card-mobile {
    aspect-ratio: auto;
    align-items: stretch;
    justify-content: flex-start;
    background-color: var(--bg-card);
  }

  /* the screenshot becomes a normal in-flow block at the top of the card */
  html .work-card-mobile .work-card-bg {
    position: relative;
    top: auto;
    right: auto;
    bottom: auto;
    left: auto;
    flex: 0 0 auto;
    width: 100%;
    height: auto;
    aspect-ratio: 16 / 10;
    background-position: center top;
    border-bottom: 1px solid var(--border-color);
  }

  /* with the text outside the image the dark scrim is no longer needed */
  html .work-card-mobile .work-card-overlay {
    display: none;
  }

  /* text block sits below the image on its own solid background */
  html .work-card-mobile .work-card-content {
    position: relative;
    z-index: 1;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    background-color: var(--bg-card);
    padding: var(--space-1-m) var(--space-1-s) var(--space-1-s);
  }

  html .work-card-mobile .work-card-number {
    letter-spacing: 2px;
  }

  html .work-card-mobile .work-card-title {
    font-size: 1.35rem;
    margin-top: var(--space-1-3xs);
    margin-bottom: var(--space-1-4xs);
  }

  html .work-card-mobile .work-card-desc {
    margin-bottom: var(--space-1-2xs);
  }

  html .work-card-mobile .work-card-link {
    color: var(--accent-red);
  }

  /* the hover zoom would now scale the image out over the text below it */
  html .work-card-mobile:hover .work-card-bg {
    transform: none;
  }
}


/* src/styles/work-card-tablet.css */
/* Work cards — tablet & mid-size screens (769px → 1700px)
   ------------------------------------------------------------------
   Same treatment as src/styles/work-card-mobile.css, one step up:

   On anything narrower than a large desktop the generated site screenshot
   is still too busy to carry text on top of it, so the card stacks instead:

       image (16/10, full card width) on top  →  solid text block underneath

   The 2-column .work-grid from the base sheet is kept, so the tablet /
   mid-size layout reads as two stacked cards per row.
   Above 1700px the original desktop design (text over the image) stays,
   and the mobile sheet still owns everything at 768px and below.
   ------------------------------------------------------------------ */

@media (min-width: 769px) and (max-width: 1700px) {
  /* card is no longer a fixed-ratio frame — it grows with its stacked content */
  html .work-card {
    aspect-ratio: auto;
    align-items: stretch;
    justify-content: flex-start;
    background-color: var(--bg-card);
  }

  /* the screenshot becomes a normal in-flow block at the top of the card */
  html .work-card .work-card-bg {
    position: relative;
    top: auto;
    right: auto;
    bottom: auto;
    left: auto;
    flex: 0 0 auto;
    width: 100%;
    height: auto;
    aspect-ratio: 16 / 10;
    background-position: center top;
    border-bottom: 1px solid var(--border-color);
  }

  /* with the text outside the image the dark scrim is no longer needed */
  html .work-card .work-card-overlay {
    display: none;
  }

  /* text block sits below the image on its own solid background */
  html .work-card .work-card-content {
    position: relative;
    z-index: 1;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    background-color: var(--bg-card);
    padding: var(--space-1-m);
  }

  html .work-card .work-card-desc {
    margin-bottom: var(--space-1-s);
    max-width: 52ch;
  }

  html .work-card .work-card-link {
    color: var(--accent-red);
  }

  /* the hover zoom would now scale the in-flow image out over the text below it */
  html .work-card:hover .work-card-bg {
    transform: none;
  }
}


/* src/styles/copy-email.css */
/* Copy-to-clipboard control — contact page email line
   ------------------------------------------------------------------
   The "Click to Copy my Email" line carries a round copy button that
   copies designbyfaisalahmad@gmail.com to the clipboard
   (see src/scripts/copy-email.js).

   The icon is a CSS mask painted with the current text colour, so it
   follows the accent tokens instead of a baked-in image colour:
     rest   → copy icon, muted
     hover  → the shared red "water fill" (cursor.css / .js-red-fill),
              so the icon flips to white to stay readable
     copied → check mark + "Copied!" tooltip for ~2s
   ------------------------------------------------------------------ */

html .copy-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 38px;
  height: 38px;
  padding: 0;
  background-color: transparent;
  border: 1px solid var(--border-color);
  border-radius: 999px;
  color: var(--text-secondary);
  transition-property: color, border-color, background-color, transform;
  transition-duration: 0.3s;
  transition-timing-function: ease;
}

/* the icon: copy glyph, painted with the current colour */
html .copy-btn::before {
  content: "";
  display: block;
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  background-color: currentcolor;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='%23000'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Crect%20x='9'%20y='9'%20width='12'%20height='12'%20rx='2'/%3E%3Cpath%20d='M5%2015H4a2%202%200%200%201-2-2V4a2%202%200%200%201%202-2h9a2%202%200%200%201%202%202v1'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='%23000'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Crect%20x='9'%20y='9'%20width='12'%20height='12'%20rx='2'/%3E%3Cpath%20d='M5%2015H4a2%202%200%200%201-2-2V4a2%202%200%200%201%202-2h9a2%202%200%200%201%202%202v1'/%3E%3C/svg%3E");
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: contain;
  mask-size: contain;
}

/* copied state — swappable for the moment the :hover fill is not active */
html .copy-btn.is-copied {
  color: var(--accent-red);
  border-color: var(--accent-red);
}

html .copy-btn.is-copied::before {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='%23000'%20stroke-width='2.6'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M20%206%209%2017l-5-5'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='%23000'%20stroke-width='2.6'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M20%206%209%2017l-5-5'/%3E%3C/svg%3E");
}

/* hover: border goes red and the icon flips white so it survives the
   red "water fill" the cursor script spreads from the entry point */
html .copy-btn:hover {
  color: var(--text-white);
  border-color: var(--accent-red);
  background-color: var(--accent-red);
  transform: translateY(-1px);
}

html .copy-btn:focus-visible {
  outline: 2px solid var(--accent-red);
  outline-offset: 3px;
}

/* "Copied!" tooltip */
html .copy-btn::after {
  content: "Copied!";
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%) translateY(4px);
  background-color: var(--bg-card);
  border: 1px solid var(--border-color);
  border-radius: 6px;
  color: var(--accent-red);
  font-family: var(--font-primary);
  font-size: 0.7rem;
  font-weight: 600;
  letter-spacing: 0.5px;
  padding: 6px 10px;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition-property: opacity, transform;
  transition-duration: 0.2s;
  transition-timing-function: ease;
}

html .copy-btn.is-copied::after {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

@media (max-width: 768px) {
  html .copy-btn {
    width: 34px;
    height: 34px;
  }
}

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


/* src/styles/form-submit-sequence.css */
/* Contact form — the send sequence
   ==================================================================
   Fields leave one by one  →  the note arrives line by line  →  the fields
   come back one by one. Driven by src/scripts/form-submit-sequence.js,
   which only ever toggles a few classes and one custom property per field.

     .fa-form-seq   armed (added once, at init) — it is what makes the
                    transitions below exist at all
     .fa-seq-out    the wave that takes the fields away
     .fa-seq-in     the wave that brings them back
     .fa-word       one word of the success note
     --fa-i         this field's place in the wave (set per field)
     --fa-line      the visual line a word landed on (measured, then set)

   Two rules of the house, both non-negotiable here:

     1. NOTHING MOVES IN THE LAYOUT. Only opacity and transform are touched,
        so the section, the footer and the scroll position can never jump —
        the fields keep their exact space the whole time and simply breathe
        in and out of it.
     2. THE FORM IS NEVER HURT. The transition lives on .form-group, which no
        other script styles; the submit button is faded with an animation
        instead, because the cursor script writes an inline transform on it
        (the magnetic pull) and a transition on transform would make that
        pull feel heavy. Animations do not fight inline transforms.
   ================================================================== */

/* ------------------------------------------------------------------ *
 * 1. the fields
 * ------------------------------------------------------------------ */

html .minimal-form.fa-form-seq .form-group {
  transition-property: opacity, transform;
  transition-duration: 0.45s;
  transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
  transition-delay: 0s;
}

/* leaving — the wave reads top to bottom, ~0.05s apart. The runtime clears
   the values inside this first moment, so the clearing is covered by the
   wave instead of being the thing you see. */
html .minimal-form.fa-form-seq.fa-seq-out .form-group {
  opacity: 0;
  transform: translate3d(0, -12px, 0);
  transition-delay: calc(var(--fa-i, 0) * 48ms);
  pointer-events: none;
  will-change: opacity, transform;
}

/* coming back — the same wave, a touch slower */
html .minimal-form.fa-form-seq.fa-seq-in .form-group {
  opacity: 1;
  transform: translate3d(0, 0, 0);
  transition-delay: calc(var(--fa-i, 0) * 55ms);
  will-change: opacity, transform;
}

/* the send button leaves last and returns last; opacity only, so the
   magnetic transform the cursor script paints on it stays untouched */
html .minimal-form.fa-form-seq.fa-seq-out .submit-btn {
  animation: faSeqFieldOut 0.42s ease both;
  animation-delay: calc(var(--fa-i, 0) * 48ms);
  pointer-events: none;
}

html .minimal-form.fa-form-seq.fa-seq-in .submit-btn {
  animation: faSeqFieldIn 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
  animation-delay: calc(var(--fa-i, 0) * 55ms);
}

@keyframes faSeqFieldOut {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

@keyframes faSeqFieldIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* ------------------------------------------------------------------ *
 * 2. the note
 *
 * Each word is measured onto the line it really sits on, then every word of
 * a line rises together — so the note arrives baris demi baris instead of
 * dropping in whole. The words are spans only for as long as the entrance
 * lasts; the script puts the plain text back the moment the last line has
 * landed, handing the runtime the exact DOM it wrote.
 * ------------------------------------------------------------------ */

html .contact-form-container .form-status .fa-word {
  display: inline-block;
  opacity: 0;
  transform: translate3d(0, 0.55em, 0);
  animation: faSeqWordIn 0.52s cubic-bezier(0.19, 1, 0.22, 1) both;
  animation-delay: calc(var(--fa-line, 0) * 90ms);
  will-change: opacity, transform;
}

@keyframes faSeqWordIn {
  from {
    opacity: 0;
    transform: translate3d(0, 0.55em, 0);
  }
  to {
    opacity: 1;
    transform: translate3d(0, 0, 0);
  }
}

/* ------------------------------------------------------------------ *
 * 3. reduced motion — the send reads exactly as it always did
 * ------------------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  html .minimal-form.fa-form-seq .form-group,
  html .minimal-form.fa-form-seq .submit-btn,
  html .contact-form-container .form-status .fa-word {
    transition-duration: 0.01ms;
    transition-delay: 0ms;
    animation-duration: 0.01ms;
    animation-delay: 0ms;
  }
}


/* src/styles/form-field-error.css */
/* Contact form — a field that is in the way
   ------------------------------------------------------------------
   Driven by src/scripts/form-email-guard.js, which owns the field's error
   state for the email control: the note no longer appears under the form,
   the field itself says what is wrong.

     .fa-field-invalid   on the field's .form-group — red underline + label
     .fa-field-flag      the "!" badge the script appends to that group

   The badge is a real element positioned OUT OF FLOW, so the form's rhythm
   is untouched: the label, the input and every gap keep their exact size
   while the mark appears and disappears. Nothing here animates layout.
   ------------------------------------------------------------------ */

html .minimal-form .form-group.fa-field-invalid {
  position: relative;
}

html .minimal-form .form-group.fa-field-invalid .form-label {
  color: var(--accent-red);
}

html .minimal-form .form-group.fa-field-invalid .form-input {
  border-bottom-color: var(--accent-red);
  box-shadow: 0 1px 0 0 var(--accent-red);
}

html .minimal-form .form-group.fa-field-invalid .form-input:focus {
  border-bottom-color: var(--accent-red);
}

html .minimal-form .form-group.fa-field-invalid .form-input::placeholder {
  color: color-mix(in srgb, var(--accent-red) 55%, var(--text-muted));
}

/* the mark itself — sits on the input's own line, at its right edge */
html .minimal-form .form-group .fa-field-flag {
  position: absolute;
  right: 0;
  bottom: 18px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 22px;
  height: 22px;
  border-radius: 999px;
  background-color: var(--accent-red);
  color: var(--text-white);
  font-family: var(--font-primary);
  font-size: 0.85rem;
  font-weight: 700;
  font-style: normal;
  line-height: 1;
  letter-spacing: 0;
  pointer-events: none;
  animation: faFieldFlagIn 0.42s cubic-bezier(0.19, 1, 0.22, 1) both;
}

@keyframes faFieldFlagIn {
  from {
    opacity: 0;
    transform: translate3d(0, 0.5rem, 0) scale(0.55);
  }
  to {
    opacity: 1;
    transform: translate3d(0, 0, 0) scale(1);
  }
}

@media (max-width: 375px) {
  html .minimal-form .form-group .fa-field-flag {
    width: 20px;
    height: 20px;
    bottom: 17px;
    font-size: 0.78rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  html .minimal-form .form-group .fa-field-flag {
    animation: none;
  }
}


/* src/styles/header-scroll.css */
/* Header — SCROLLED stage (all breakpoints)
   ------------------------------------------------------------------
   #main-header (the everywhere-layout chrome) starts fully transparent so
   the hero reads edge-to-edge. As soon as the page moves, navigation.js
   puts .scrolled on it and the header animates into a frosted bar:

       transparent stage  →  background + hairline border + soft shadow

   The transition itself is declared on the base header (see the layout
   document), so every breakpoint animates the same smooth way:
   0.45s cubic-bezier(0.25, 1, 0.5, 1) — no snap, no flicker.

   Ordering note: this sheet is priority 190, i.e. before
   src/styles/mobile-menu.css (200), so `body.menu-open .main-header`
   still keeps the header flat while the mobile menu is open.
   ------------------------------------------------------------------ */

html .main-header {
  transition-property: background-color, border-color, box-shadow, backdrop-filter, -webkit-backdrop-filter, padding;
}

/* ---- desktop / base ---------------------------------------------------- */
html .main-header.scrolled {
  /* SOLID --bg-black, the exact colour the page itself is painted with
     (html/body: rgb(0, 0, 0)) — the bar IS the page, so nothing behind it
     can tint it blue/grey. No transparency and no saturating filter are left
     here: an earlier 88%-black + saturate(140%) frosted mix was what read as
     a blue bar. */
  background-color: var(--bg-black);
  border-bottom-color: var(--border-color);
  box-shadow: rgba(0, 0, 0, 0.45) 0px 12px 34px -22px;
  /* only the vertical rhythm compacts — horizontal padding stays untouched,
     so the logo / nav / CTA keep their exact desktop positions */
  padding-top: 16px;
  padding-bottom: 16px;
}

/* ---- tablet (≤768px) --------------------------------------------------- */
@media (max-width: 768px) {
  html .main-header.scrolled {
    /* same solid --bg-black as desktop — the bar never tints what is behind
       it and never lets content show through, whatever the screen */
    background-color: var(--bg-black);
    padding-top: 14px;
    padding-bottom: 14px;
  }
}

/* ---- mobile (≤375px) --------------------------------------------------- */
@media (max-width: 375px) {
  html .main-header.scrolled {
    padding-top: 12px;
    padding-bottom: 12px;
  }
}

/* ---- accessibility: no motion, just the state change ------------------- */
@media (prefers-reduced-motion: reduce) {
  html .main-header {
    transition-duration: 0.01ms;
  }
}


/* src/styles/header-intro.css */
/* Header intro — the load-in entrance (all breakpoints)
   ==================================================================
   The global header (the everywhere-layout chrome) is the first thing a
   visitor sees, so it arrives the same way the home hero already does:
   every label slides up out of its own clip — that masked line-reveal is
   the signature of this site, reused here for

       the logo  ·  each menu item  ·  the "Let's Talk" button

   so the two entrances read as one piece of choreography rather than two.

   STRUCTURE (carried by the layout document):
       <span class="header-mask">   the clip  → overflow hidden
         <span class="header-rise"> the line  → travels up into place

   THE FOUR RULES THIS SHEET OBEYS
     1. ONLY opacity + transform are animated, so nothing in the header can
        change its size or position — the page's height and scroll position
        at first paint are untouched.
     2. THE ENTRANCE NEVER STICKS. Every animation here uses fill-mode
        `backwards`, never `both`/`forwards`. Once the entrance has played,
        the element is back on its own styles, so the hover underline, the
        magnetic cursor, the red "water fill", the hamburger's X morph and
        the .scrolled header state all behave exactly as they did before.
        A held `forwards` fill would have frozen those states.
     3. NOTHING BUT THE HEADER IS TOUCHED — every selector is scoped under
        `html .main-header`.
     4. NO MOTION WHEN MOTION IS NOT WANTED — see the end of the sheet.

   THE RHYTHM — the header leads and the hero answers it (the hero's own
   delays live in the page: badge 100ms, title lines 180/310ms, …):
       logo 280ms (1.2s travel) · Home 150 · Work 205 · About 260
       Service 315 · Blog 370 · Contact 425
       CTA frame 470 · CTA label 520 · CTA arrow 700
       hamburger bars 280
   ================================================================== */

/* ------------------------------------------------------------------ *
 * 1. the clip and the line
 * ------------------------------------------------------------------ */

html .main-header .header-mask {
  display: block;
  overflow: hidden;
  /* room for the descenders of a lower-case label once the line has landed:
     the mask is grown and pulled back by the same amount, so it contributes
     nothing to the header's height */
  padding-bottom: 0.14em;
  margin-bottom: -0.14em;
}

html .main-header .header-rise {
  display: block;
  animation-name: faHeaderRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 0s;
  animation-iteration-count: 1;
  animation-direction: normal;
  /* backwards — the line waits below the clip during its delay, and the
     element is released again the instant the entrance ends */
  animation-fill-mode: backwards;
  animation-play-state: running;
  will-change: transform;
}

/* the hero's own line-reveal move, reused verbatim */
@keyframes faHeaderRise {
  0% {
    transform: translate3d(0, 118%, 0);
  }
  100% {
    transform: translate3d(0, 0, 0);
  }
}

/* ------------------------------------------------------------------ *
 * 2. the rhythm — one item after another, left to right
 * ------------------------------------------------------------------ */

/* the logo runs on its own beat: it waits 200ms longer than it used to
   (80ms → 280ms) and travels a touch slower (0.95s → 1.2s), so the wordmark
   settles into place after the header's first labels instead of with them.
   Only these two values change — the clip, the easing and the fill-mode stay
   exactly as they are. */
html .main-header .header-logo .header-rise {
  animation-delay: 280ms;
  animation-duration: 1.2s;
}

html .main-header .desktop-nav .nav-item:nth-child(1) .header-rise {
  animation-delay: 150ms;
}

html .main-header .desktop-nav .nav-item:nth-child(2) .header-rise {
  animation-delay: 205ms;
}

html .main-header .desktop-nav .nav-item:nth-child(3) .header-rise {
  animation-delay: 260ms;
}

html .main-header .desktop-nav .nav-item:nth-child(4) .header-rise {
  animation-delay: 315ms;
}

/* the two later items (Service, Blog) continue the same 55ms walk, so a
   longer menu still reads as one single left-to-right sweep instead of the
   last items snapping in at 0ms */
html .main-header .desktop-nav .nav-item:nth-child(5) .header-rise {
  animation-delay: 370ms;
}

html .main-header .desktop-nav .nav-item:nth-child(6) .header-rise {
  animation-delay: 425ms;
}

html .main-header .header-cta .header-rise {
  animation-delay: 520ms;
}

/* ------------------------------------------------------------------ *
 * 3. the parts that are not a line
 *
 *    The CTA has a frame and an arrow, the hamburger has two bars. None of
 *    these is a masked text line, so they simply fade in — and the CTA only
 *    ever animates its OPACITY, because the cursor script writes an inline
 *    transform on it (the magnetic pull) and an animation on transform
 *    would fight that.
 * ------------------------------------------------------------------ */

@keyframes faHeaderFadeIn {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

/* the CTA frame arrives just before its label rises out of the clip */
html .main-header .header-cta {
  animation-name: faHeaderFadeIn;
  animation-duration: 0.5s;
  animation-timing-function: ease;
  animation-delay: 470ms;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

html .main-header .header-cta::after {
  animation-name: faHeaderFadeIn;
  animation-duration: 0.5s;
  animation-timing-function: ease;
  animation-delay: 700ms;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* the two hamburger bars close in on each other from above and below, then
   land on their resting offsets — the same offsets (:before -4px / :after
   4px) the open/close X morph animates from, so nothing is displaced when
   this entrance hands the bars back */
@keyframes faHeaderBarTop {
  0% {
    opacity: 0;
    transform: translateY(-16px);
  }
  100% {
    opacity: 1;
    transform: translateY(-4px);
  }
}

@keyframes faHeaderBarBottom {
  0% {
    opacity: 0;
    transform: translateY(16px);
  }
  100% {
    opacity: 1;
    transform: translateY(4px);
  }
}

html .main-header .hamburger::before {
  animation-name: faHeaderBarTop;
  animation-duration: 0.72s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 280ms;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

html .main-header .hamburger::after {
  animation-name: faHeaderBarBottom;
  animation-duration: 0.72s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 280ms;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* ------------------------------------------------------------------ *
 * 4. reduced motion — the header is simply there, in its final state
 * ------------------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  html .main-header .header-rise,
  html .main-header .header-cta,
  html .main-header .header-cta::after,
  html .main-header .hamburger::before,
  html .main-header .hamburger::after {
    animation: none;
  }
}


/* src/styles/header-nav-fit.css */
/* Header menu — the current page, and the bar between the breakpoints
   ==================================================================
   The menu grew from four items to six (Home · Work · About · Service ·
   Blog · Contact). The entrance rhythm for all six already lives in
   src/styles/header-intro.css and is complete. Two consequences of the
   longer menu were still open, and both are settled here:

   1. THE CURRENT PAGE HAD NO MARKER
      src/scripts/navigation.js already writes `.active` onto the item
      whose href matches the current path — on the desktop bar
      (.nav-item) and in the overlay (.mobile-nav-item). No rule ever
      read that class, so the state was written and never seen. Here it
      lands as the RESTING form of the treatment the item already has on
      hover, so the two states speak the same language:

          desktop → white label + the red hairline held in place
          overlay → red label (the overlay's own hover colour)

      Nothing new is introduced: same colour tokens, same underline.

   2. THE BAR DID NOT FIT BETWEEN 769px AND ~830px
      "Let's Talk" is only dropped at ≤768px, where the hamburger takes
      over. Just above that — an 800px window, an iPad in portrait
      (810px CSS px) — the bar still had to hold the logo (127px), all
      six labels with their gaps (354px) and the CTA (154px) inside the
      same 60px side padding: ~812px of content in a 769–830px bar. The
      flex row had to shrink, which clipped the labels.

      This band COMPACTS the bar instead of dropping anything, and it is
      built to hand over invisibly at both edges:

        · side padding runs 30px → 60px across the band, so it meets the
          tablet bar (30px, ≤768px) at the bottom and the desktop bar
          (60px, ≥1025px) at the top — no jump in either direction
        · the menu gap tightens to a fluid 13px…20px
        · only the CTA's horizontal padding shrinks, fluidly, 14px → 22px
          (its label keeps its exact size, so the type never reflows)

      The result is ~689px of content at 769px — comfortable at any
      width in the band, with no rule applying outside it.
   ================================================================== */

/* ------------------------------------------------------------------ *
 * 1. the current page
 * ------------------------------------------------------------------ */

html .main-header .desktop-nav .nav-item.active {
  color: var(--text-white);
}

/* the hairline the hover sweeps in is simply already home */
html .main-header .desktop-nav .nav-item.active::after {
  transform: scaleX(1);
  transform-origin: left center;
}

/* hovering the current page must not un-light it */
html .main-header .desktop-nav .nav-item.active:hover::after {
  transform: scaleX(1);
  transform-origin: left center;
}

html .mobile-overlay .mobile-nav-item.active,
html .mobile-overlay .mobile-nav-item.active:hover {
  color: var(--accent-red);
}

/* ------------------------------------------------------------------ *
 * 2. the fit between the tablet and the desktop bar
 * ------------------------------------------------------------------ */

@media (min-width: 769px) and (max-width: 1024px) {
  /* 30px at 769px → 60px at 1024px: continuous with both neighbours */
  html .main-header {
    padding-left: clamp(30px, calc(11.72vw - 60px), 60px);
    padding-right: clamp(30px, calc(11.72vw - 60px), 60px);
    column-gap: var(--space-1-s);
    row-gap: var(--space-1-s);
  }

  html .main-header .desktop-nav {
    column-gap: clamp(0.8125rem, 1.7vw, 1.25rem);
    row-gap: clamp(0.8125rem, 1.7vw, 1.25rem);
  }

  /* label untouched — only the frame gets narrower */
  html .main-header .magnetic-btn.header-cta {
    padding-left: clamp(14px, calc(3.125vw - 10px), 22px);
    padding-right: clamp(14px, calc(3.125vw - 10px), 22px);
  }
}

/* ------------------------------------------------------------------ *
 * 3. reduced motion — nothing here moves, so there is nothing to undo
 * ------------------------------------------------------------------ */


/* src/styles/mobile-menu.css */
/* Mobile / tablet navigation overlay — OPEN state
   The hamburger (≤768px) toggles:
     .hamburger.active  → bars morph into an X
     .mobile-overlay.active → full-screen menu becomes visible
     body.menu-open → page scroll is locked
   The header is raised above the overlay (z-index 1002 > 1001) so the close
   button stays visible and clickable while the menu is open. */

body.menu-open {
  overflow: hidden;
}

/* Header stays clean/flat while the menu is open — including while the page
   is scrolled, where the bar is otherwise painted solid --bg-black (the
   over-qualified selector here keeps this flat state winning over that one) */
body.menu-open .main-header,
html body.menu-open .main-header.scrolled {
  background-color: transparent;
  border-bottom-color: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

.mobile-overlay.active {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
}

/* Staggered reveal — --i is set per item by navigation.js */
.mobile-overlay.active .mobile-nav-item,
.mobile-overlay.active .mobile-cta {
  opacity: 1;
  transform: translateY(0);
}

/* Hamburger: two bars morph into a cross */
.hamburger.active::before {
  transform: translateY(0) rotate(45deg);
}

.hamburger.active::after {
  transform: translateY(0) rotate(-45deg);
}

.hamburger:hover::before,
.hamburger:hover::after,
.hamburger.active:hover::before,
.hamburger.active:hover::after {
  background-color: var(--accent-red, #ff3333);
}

@media (prefers-reduced-motion: reduce) {
  .mobile-overlay,
  .mobile-nav-item,
  .mobile-cta,
  .hamburger::before,
  .hamburger::after {
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
  }
}


/* src/styles/page-entrance.css */
/* Page entrance — the home hero's rhythm, on every other page
   ==================================================================
   The home page opens with .hero-section: masked headline lines sliding
   up one after another, chips blurring in, the copy rising, the status
   dot pulsing — page CSS built on the faHero* keyframes.

   The inner pages (/work, /about, /contact) are wrapped by the everywhere
   layout and their hero is the plain .page-header block, which had no
   entrance of its own. This sheet gives them exactly the same vocabulary:

     .fa-line        mask container — its child slides up from behind it
     .fa-line > *    the travelling line
     .fa-chip        blur + rise  (badges, chips, tags, labels)
     .fa-rise        fade + rise  (copy, buttons, rows)
     .fa-dot         the infinite pulse of a status dot

   Usage:
     <div class="fa-line"><h1 class="page-title">Headline</h1></div>

   Every inner-page header uses the mask for its title and the rise for its
   subtitle, on the same beat as the home hero (title first, copy after).
   src/scripts/reveal.js deliberately leaves .page-header alone, so this is
   the only entrance that ever touches it.

   Only opacity / transform / filter are animated, every animation fills
   `backwards` and ends on the element's own styles — so hover states, the
   red water-fill, the magnetic cursor and the scroll-reveal all keep
   behaving exactly as before.
   ================================================================== */

@keyframes faHeroLine {
  from {
    transform: translate3d(0, 118%, 0);
  }

  to {
    transform: translate3d(0, 0, 0);
  }
}

@keyframes faHeroChip {
  from {
    opacity: 0;
    transform: translate3d(0, 14px, 0);
    filter: blur(7px);
  }

  to {
    opacity: 1;
    transform: translate3d(0, 0, 0);
    filter: blur(0);
  }
}

@keyframes faHeroRise {
  from {
    opacity: 0;
    transform: translate3d(0, 18px, 0);
  }

  to {
    opacity: 1;
    transform: translate3d(0, 0, 0);
  }
}

@keyframes faHeroDot {
  0%,
  100% {
    box-shadow: 0 0 6px var(--accent-red);
  }

  50% {
    box-shadow: 0 0 15px var(--accent-red);
  }
}

/* --- the mask: the line slides up from behind its own box ------------- */

.fa-line {
  display: block;
  overflow: hidden;
  padding-bottom: 0.12em;
  margin-bottom: -0.12em;
}

.fa-line > * {
  animation-name: faHeroLine;
  animation-duration: 1.05s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 0s;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* --- the two smaller entrances ---------------------------------------- */

.fa-chip {
  animation-name: faHeroChip;
  animation-duration: 0.9s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 0s;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

.fa-rise {
  animation-name: faHeroRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 0s;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

.fa-dot {
  animation-name: faHeroDot;
  animation-duration: 2.6s;
  animation-timing-function: ease-in-out;
  animation-delay: 0s;
  animation-iteration-count: infinite;
}

/* --- the inner-page hero: the header of /work, /about and /contact ---- */

/* THE HEADER'S OWN BOX AND BEAT, STATED AUTHORITATIVELY.
   The mask, the padding guard around it and the beat the line leaves on are
   geometry: a value that a sheet arriving after this one could still
   re-decide would change the line's travel after the first paint, and the
   line would snap into its place instead of sliding into it. /about
   (src/styles/about-hero-entrance.css) and /work
   (src/styles/work-entrance.css) each name their own header's box this way
   and outrank this declaration by priority; /contact has no entrance sheet
   of its own, so it is this rule — the same values, the same contract — that
   gives it a first paint identical to its last. */

html .page-header .fa-line {
  display: block;
  overflow: hidden;
  height: auto !important;
  min-height: 0 !important;
  padding-bottom: 0.12em !important;
  margin-bottom: -0.12em !important;
}

html .page-header .fa-line > * {
  animation-name: faHeroLine !important;
  animation-duration: 1.05s !important;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1) !important;
  animation-delay: 140ms !important;
  animation-iteration-count: 1 !important;
  animation-direction: normal !important;
  animation-fill-mode: backwards !important;
  animation-play-state: running !important;
}

/* every other direct part of an inner-page header — a badge, a chip, a meta
   line, the subtitle — arrives on its own beat as well, so no part of a page
   header is ever left unannounced while the title animates */

html .page-header > *:not(.fa-line) {
  animation-name: faHeroRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 430ms;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

html .page-header .page-subtitle {
  animation-name: faHeroRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 430ms;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* THE ACCENT SLASH RIDES ITS LINE.

   An accent INSIDE a masked title — the "/" of a page header — is part of
   that line: it leaves the clip together with the headline it belongs to.
   Animating it here as well put a second entrance on top of a line that was
   still travelling: the slash blurred and rose under a title that was still
   sliding (90ms against the line's 140ms), i.e. two entrances fighting over
   the same pixels — the jump this rule removes.

   /about (src/styles/about-hero-entrance.css) and /work
   (src/styles/work-entrance.css) already switched it off for their own
   headers; saying it on the shared selector hands /contact — the one inner
   page with no entrance sheet of its own — the same clean masked slide, and
   keeps every header on one rhythm instead of two.

   An accent that is NOT inside a mask is not covered by this selector and
   keeps whatever entrance its own page declares. */

html .page-header .fa-line .accent-text,

/* THE LINE'S OWN CLASS STOPS AT THE LINE.
   The inner-page titles carry their breakpoint class on the heading AND on
   the accent inside it —

       <h1 class="page-title aboutme-header aboutme-header-mobile">
         <span class="accent-text aboutme-header-mobile">/</span>SELECTED WORKS
       </h1>

   — so every rule aimed at the line reaches the accent a second time as
   well: it was armed twice by everything that paints a header, which is
   what made the slash travel twice the distance of its line and land on a
   beat of its own. The wrapper moves and the accent rides it, exactly as
   the home hero's own title line does; the repeated class buys it no
   entrance of its own here. */

html .page-header .fa-line > .page-title > .accent-text,
html .page-header .fa-line > .page-title > .aboutme-header,
html .page-header .fa-line > .page-title > .aboutme-header-mobile {
  animation: none !important;
  filter: none !important;
  opacity: 1 !important;
  transform: none !important;
}

/* --- none of this ever runs for reduced motion ------------------------ */

@media (prefers-reduced-motion: reduce) {
  .fa-line > *,
  .fa-chip,
  .fa-rise,
  .fa-dot,
  html .page-header > *:not(.fa-line),
  html .page-header .page-subtitle,
  html .page-header .fa-line .accent-text {
    animation-name: none;
    animation-duration: auto;
    animation-delay: 0s;
    animation-iteration-count: 1;
    animation-fill-mode: none;
  }
}


/* src/styles/reveal-contact-page-2309.css */
/* src/styles/reveal-contact-page-2309.css
   Contact page reveal — written 23 September 2026 (2309)
   ==================================================================
   REQUEST: "reveal animasi untuk halaman contact" — dan setiap file baru
   diberi nomor tanggal pembuatannya di belakang, supaya bisa dilacak: file
   mana mengatur apa, dan kapan dibuat.

       src/styles/reveal-contact-page-2309.css   ← file ini   (gerakannya)
       src/scripts/reveal-contact-page-2309.js   ← pasangannya (pemicunya)

   KONDISI HALAMAN SEBELUM INI
   ------------------------------------------------------------------
   · Header /contact ("/GET IN TOUCH" + subjudulnya) SUDAH punya entrance
     masked line yang sama dengan hero home, dan itu milik
     src/styles/page-entrance.css (judul 140ms, subjudul 430ms). Sheet ini
     TIDAK menyentuhnya — satu bagian, satu pemilik.
   · Yang belum punya entrance: seluruh blok di bawah header — panel kontak,
     formnya, dan footer. Ketiganya hanya muncul begitu saja saat halaman
     dibuka, karena sampai sekarang footer-lah yang dulu di-arm oleh
     src/scripts/reveal.js yang sudah dihapus.
   · Footer halaman ini masih membawa prop inline dari sistem reveal lama
     (--fa-reveal-delay / --fa-reveal-dur / --fa-reveal-y). Sheet ini dan
     pasangannya MEMAKAI ULANG prop itu, bukan mengarang nilai baru — jadi
     tempo footer tetap seperti yang halaman ini sendiri sudah tulis.

   DUA BAGIAN, DAN KENAPA HARUS DUA
   ------------------------------------------------------------------
   1. BAGIAN YANG TERLIHAT SAAT HALAMAN DIBUKA — murni CSS (bagian 1 di
      bawah). Tanpa script, tanpa toggle class, tanpa menunggu viewport: dia
      ikut terputar di SETIAP load. Isinya tidak bisa nyangkut sembunyi,
      karena penyembunyiannya hanya datang dari `from` milik animasinya
      sendiri (animation-fill-mode: backwards) dan setiap animasi berakhir
      kembali di style asli elemennya. Kalau ada satu rule yang gagal
      berlaku, isinya hanya akan TERLIHAT BIASA — bukan hilang.
   2. BAGIAN DI BAWAH LIPATAN — footer (bagian 2). Ini di-arm oleh
      src/scripts/reveal-contact-page-2309.js: script itu memasang
      `fa-reveal-contact-2309` di <html> ("script peng-arm-nya hidup"), dan
      sheet ini menyembunyikan target footer HANYA di bawah flag itu; script
      menambahkan `fa-in` begitu footer masuk viewport. Kalau scriptnya
      hilang, flag tidak pernah ada dan footer terlihat biasa — jadi
      kegagalannya selalu "tanpa animasi", bukan "tanpa isi".

   KOSAKATA — milik rumah ini sendiri, tidak ada yang baru
   (didefinisikan di src/styles/page-entrance.css):
       faHeroLine   masked line slide-up        (tidak dipakai di sini —
                                                 header punya miliknya sendiri)
       faHeroChip   blur + rise                 ("Let's Collab.")
       faHeroRise   fade + rise                 (blok, field, baris)
   /contact harus terbaca sebagai koreografi yang sama dengan hero home,
   /about dan /work — jadi tidak ada move, easing atau durasi baru.

   TEMPO — header memimpin, panel menjawabnya:
       /GET IN TOUCH        140ms   (page-entrance.css, tidak disentuh)
       subjudulnya          430ms   (page-entrance.css, tidak disentuh)
       "Let's Collab."      620ms                              faHeroChip
       tiga blok kontak     620 / 700 / 780ms                  faHeroRise
       field 01…06          700ms + (--fa-i × 70ms)            faHeroRise
       tombol Send          seri yang sama, OPACITY saja
       footer               saat di-scroll (script) — 120ms / 240ms, dari
                            prop inline yang halaman ini memang sudah bawa

   ATURAN RUMAH YANG DIPEGANG
   ------------------------------------------------------------------
   · TIDAK ADA YANG MENGGESER LAYOUT. Hanya opacity / transform / filter
     yang dianimasikan — tidak ada ukuran yang berubah, jadi tinggi halaman
     dan posisi scroll saat first paint persis sama seperti sebelumnya.
   · TIDAK ADA ENTRANCE KEDUA PADA SEBUAH ANAK. Sebuah transform membawa
     seluruh subtree-nya, jadi blok yang naik membawa label dan isinya
     sekalian; tidak ada anak dari blok yang dianimasikan lagi di sini
     (kesalahan "travel dobel" yang sudah dibereskan di /about dan di slash
     merah pada header). Satu pengecualian yang disengaja: dua baris footer
     ("Looking forward to your message" dan "(:)") — lihat catatan di
     bagian 2, keduanya memang punya beat sendiri.
   · FORMNYA TIDAK DIRUGIKAN. .form-group hanya menerima animation, dan
     tombol submit hanya di-fade lewat OPACITY — script cursor menulis
     transform inline di tombol itu (tarikan magnetic), dan animasi pada
     transform akan melawan itu. src/styles/form-submit-sequence.css tetap
     memegang aturan kirim/kembalinya: selector-nya lebih spesifik, jadi
     selalu menang begitu pengunjung benar-benar mengirim form.
   · PREFERENSI GERAK. Mengikuti preseden /about
     (src/styles/about-entrance.css), entrance ini SENGAJA tidak dimatikan
     untuk `prefers-reduced-motion`: ini identitas halaman dan permintaannya
     adalah reveal-nya ada di setiap load. Blok reduced di bagian 3 hanya
     MENCABUT JARAKNYA (fade tetap sebuah reveal, terbang bukan). Untuk
     mengembalikan keputusan ke OS, blok opt-out di paling bawah cukup
     di-uncomment.
   ================================================================== */


/* ------------------------------------------------------------------ *
 * 1. PANELNYA — first view /contact, di setiap load
 * ------------------------------------------------------------------ */

/* tiga blok kontak (EMAIL, WHATSAPP, LOCATION).
   Blok-nya yang naik — label dan isinya ikut terbawa, tidak pernah naik
   sendiri-sendiri.
   Targetnya "anak langsung .contact-info", bukan nama class di dalamnya:
   kalau suatu hari bloknya diganti nama, entrance ini tidak ikut mati. */

html .contact-info > * {
  animation-name: faHeroRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 620ms;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

html .contact-info > *:nth-child(2) {
  animation-delay: 700ms;
}

html .contact-info > *:nth-child(3) {
  animation-delay: 780ms;
}

/* "Let's Collab." — label milik formnya datang seperti chip di hero:
   naik pendek sambil menajam keluar dari blur (faHeroChip).
   Selain .form-header, setiap heading yang jadi baris pertama container
   ikut terdaftar, supaya baris pembuka ini tidak bisa dibiarkan tanpa
   entrance hanya karena namanya berubah. */

html .contact-form-container > .form-header,
html .contact-form-container > h1,
html .contact-form-container > h2,
html .contact-form-container > h3 {
  animation-name: faHeroChip;
  animation-duration: 0.9s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 620ms;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* enam field, satu per satu.
   Halaman ini sudah menomori field-nya sendiri — setiap .form-group membawa
   style="--fa-i: 0…5" untuk send sequence (lihat
   src/styles/form-submit-sequence.css) — jadi entrance ini membaca nomor
   milik halaman itu, bukan menghitung anak: urutannya sama, sumber
   kebenarannya satu, dan tidak ada yang harus disinkronkan manual kalau ada
   field ditambah. */

html .minimal-form .form-group {
  animation-name: faHeroRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: calc(700ms + var(--fa-i, 0) * 70ms);
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* tombol Send menutup serinya (dia membawa --fa-i: 6).
   OPACITY SAJA: script cursor menulis transform inline di .magnetic-btn,
   dan animasi pada transform akan melawan tarikan itu. */

@keyframes faContactFadeIn {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

html .minimal-form .submit-btn {
  animation-name: faContactFadeIn;
  animation-duration: 0.6s;
  animation-timing-function: ease;
  animation-delay: calc(700ms + var(--fa-i, 0) * 70ms);
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}


/* ------------------------------------------------------------------ *
 * 2. FOOTERNYA — di-arm script, dibuka saat di-scroll
 *
 *    Hanya berlaku di bawah `html.fa-reveal-contact-2309`, yang cuma ada
 *    selama src/scripts/reveal-contact-page-2309.js berjalan. Temponya
 *    datang dari prop yang halaman ini sudah bawa:
 *        --fa-reveal-delay: 120ms / 240ms   --fa-reveal-dur: 1000ms
 *        --fa-reveal-y: 34px
 *    Semuanya punya nilai cadangan, jadi kalau salah satu prop hilang,
 *    barisnya tetap muncul dengan tempo yang wajar — bukan diam.
 *
 *    KAPAN sebuah baris dipicu juga milik pasangannya, dan ambangnya
 *    dihitung PER BARIS. Rumah ini memicu reveal saat barisnya 10% masuk
 *    dari tepi bawah viewport, dan copyright — anak terakhir footer — tidak
 *    akan pernah bisa menempuh garis itu: dari posisi tersembunyinya ke
 *    dasar footer hanya ~48px, sementara garisnya ~90px di viewport 900px
 *    (dan bertambah besar di layar yang lebih tinggi). Lihat `reach()` /
 *    `marginFor()` di src/scripts/reveal-contact-page-2309.js: ambang baris
 *    itu menyusut sedalam yang benar-benar bisa ditempuh, jadi ia dipicu di
 *    setiap tinggi layar — dan tetap bergerak dengan tempo 240ms yang sama
 *    seperti dua baris di atasnya.
 * ------------------------------------------------------------------ */

html.fa-reveal-contact-2309 footer .fa-reveal {
  opacity: 0;
  transform: translate3d(0, var(--fa-reveal-y, 32px), 0);
  transition-property: opacity, transform;
  transition-duration: var(--fa-reveal-dur, 1000ms);
  transition-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  transition-delay: var(--fa-reveal-delay, 0ms);
  will-change: opacity, transform;
}

/* DUA BARIS FOOTER = DUA BEAT, BUKAN SATU.
   "Looking forward to your message" dan "(:)" ditulis BERSARANG di markup
   (<p> di dalam <p>) — tapi HTML tidak mengizinkan itu: parser browser
   menutup <p> yang luar, sehingga accent-nya berakhir sebagai SAUDARA dari
   barisnya, bukan anaknya. Karena itulah halaman ini memberi mereka delay
   yang berbeda — 120ms untuk barisnya, 240ms untuk accent-nya — dan memang
   begitu yang terjadi live: dua reveal terpisah, accent menyusul satu beat.

   Jadi TIDAK ada aturan "anak dari reveal jangan ikut reveal" di sini.
   Aturan itu justru akan membungkam accent-nya di editor canvas — pohon
   editornya masih tampak bersarang karena canvas membangun DOM-nya sendiri —
   dan membuat hasil di editor berbeda dari hasil di live.

   Dibiarkan begini: keduanya mendarat di opacity 1 / transform 0, jadi apa
   pun bentuk pohonnya halaman berakhir di gambar yang sama. */

/* reveal-nya sendiri — class ini dipasang script sekali saja, saat footer
   masuk viewport */

html.fa-reveal-contact-2309 footer .fa-reveal.fa-in {
  opacity: 1;
  transform: translate3d(0, 0, 0);
}

/* CATATAN KOSAKATA — `.fa-reveal-fade` adalah varian rumah (lihat
   src/styles/footer.css) untuk reveal yang TIDAK BOLEH bergerak: elemen yang
   transform-nya milik script cursor, yaitu tombol magnetic dan copy button.
   Footer /contact tidak memakai satu pun, jadi varian itu sengaja tidak
   di-address di sini. Kalau nanti ditambahkan, dia harus ditambahkan di DUA
   tempat sekaligus — daftar target di sheet ini dan TARGETS di
   src/scripts/reveal-contact-page-2309.js — karena elemen yang disembunyikan
   tanpa pernah di-arm adalah satu-satunya cara animasi ini bisa menyakiti
   halaman. */


/* ------------------------------------------------------------------ *
 * 3. PREFERENSI GERAK — reveal-nya tetap ada, terbangnya yang dikecilkan
 * ------------------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  /* panelnya tetap "datang" di load, tapi di tempat: fade, bukan 18px naik,
     dan tanpa blur */
  html .contact-info > *,
  html .contact-form-container > .form-header,
  html .contact-form-container > h1,
  html .contact-form-container > h2,
  html .contact-form-container > h3,
  html .minimal-form .form-group {
    animation-name: faContactFadeIn;
  }

  /* footernya tetap menjawab scroll, hanya tidak menempuh 34px */
  html.fa-reveal-contact-2309 footer .fa-reveal {
    transform: none;
    transition-duration: 500ms;
  }
}

/* OPT-OUT — mengembalikan seluruh keputusan ke preferensi OS:
   cukup uncomment blok di bawah ini.

@media (prefers-reduced-motion: reduce) {
  html .contact-info > *,
  html .contact-form-container > .form-header,
  html .contact-form-container > h1,
  html .contact-form-container > h2,
  html .contact-form-container > h3,
  html .minimal-form .form-group,
  html .minimal-form .submit-btn {
    animation: none;
  }

  html.fa-reveal-contact-2309 footer .fa-reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }
}
*/


/* src/styles/reveal-home-sections-2809.css */
/* src/styles/reveal-home-sections-2809.css
   Home page — reveal untuk SETIAP section setelah hero — ditulis 28 September 2026 (2809)
   ==================================================================
   REQUEST: "Halaman homepage tidak punya animasi reveal pada section setelah
   hero section. ... pastikan animasinya bekerja di section lain, semua
   homepage, selain hero section. Karena section selain hero section belum
   ada animasinya sama sekali."

   PASANGAN — dua file bertanggal sama (2809), supaya gampang dicari:
       src/styles/reveal-home-sections-2809.css   ← file ini (gerakannya)
       src/scripts/reveal-home-sections-2809.js    ← pasangannya (pemicunya)

   CARA MENONAKTIFKAN (kalau nanti ada bagian yang tidak bekerja)
       1. Tambahkan atribut `data-fa-home-reveal-off` di <html> — script
          menyerah sebelum menyentuh apa pun, seluruh halaman tampil biasa.
       2. Atau matikan script-nya di site.runtime — flag
          `fa-reveal-home-2809` tidak pernah dipasang, dan SEMUA rule di file
          ini digantung pada flag itu, jadi tidak ada satu pun yang berlaku:
          halaman tampil apa adanya, tanpa animasi.
       3. Atau hapus pasangan file-nya sekaligus.
     Tidak ada satu pun rule di sini yang tanpa flag, jadi "script mati"
     selalu berarti "terlihat biasa", tidak pernah "kosong".

   YANG DIANIMASIKAN — tujuh blok, semuanya SETELAH hero

       .marquee-container    strip marquee di bawah hero
       .selected-work-home   "/ SELECTED WORKS" + 7 baris .work-row
       .about-sec            ABOUT FAISAL AHMAD (dua kolom)
       .svc-sec              "/ SERVICES & EXPERTISE" + 6 kartu .svc-card
       .proc-sec             "/ HOW I WORK" + 4 langkah .proc-step + CTA
       .exp-sec              "/ JOURNEY & EXPERIENCE" + 4 .exp-stat + 5 .exp-row
       footer.footer         "Let's Work Together" + baris meta

     Blok naik + fade (26px), lalu baris/kartu di dalamnya menyusul satu per
     satu (140ms + 65ms per item) — gelombangnya dihitung script
     (--fa-home-step pada tiap baris), jadi satu-satunya tempat tempo tinggal
     di bagian 1 di bawah.

   YANG TIDAK DISENTUH
     · HERO (.hero-section) — tidak ada satu selector pun di file ini yang
       menyebutnya. Entrance hero tetap milik CSS halaman + home-entrance.
     · Halaman lain (/work, /about, /contact) dan template "everywhere" —
       semua rule di sini digantung pada `html.fa-reveal-home-2809`, dan flag
       itu hanya dipasang script-nya di halaman home.
     · Hover state, marquee, cursor, form: tidak ada transition/property di
       file ini yang menyentuhnya. Animasi (bukan transition) dipakai justru
       supaya `transition-property` milik .work-row (all), .svc-card
       (background-color), .proc-step::after (width) dan seterusnya tetap utuh.
   ================================================================== */

/* ------------------------------------------------------------------ *
 * 1. SATU TEMPAT UNTUK SELURUH TEMPO
 * ------------------------------------------------------------------ */

html.fa-reveal-home-2809 {
  --fa-home-block-y: 26px;      /* jarak naik blok saat masuk */
  --fa-home-item-y: 16px;       /* jarak naik baris / kartu di dalamnya */
  --fa-home-block-dur: 0.9s;
  --fa-home-item-dur: 0.82s;
  --fa-home-item-wait: 140ms;   /* baris menyusul bloknya */
  --fa-home-item-gap: 65ms;     /* jeda antar baris dalam satu gelombang */
  --fa-home-step: 0;            /* diisi script: urutan baris (0,1,2, ...) */
}

@keyframes faHomeBlockIn2809 {
  from {
    opacity: 0;
    transform: translate3d(0, var(--fa-home-block-y, 26px), 0);
  }

  to {
    opacity: 1;
    transform: translate3d(0, 0, 0);
  }
}

@keyframes faHomeItemIn2809 {
  from {
    opacity: 0;
    transform: translate3d(0, var(--fa-home-item-y, 16px), 0);
  }

  to {
    opacity: 1;
    transform: translate3d(0, 0, 0);
  }
}

/* ------------------------------------------------------------------ *
 * 2. KEADAAN TERSEMBUNYI — hanya di bawah flag `fa-reveal-home-2809`
 *    (dipasang script-nya di head, sebelum paint pertama)
 * ------------------------------------------------------------------ */

html.fa-reveal-home-2809 .marquee-container:not(.fa-in),
html.fa-reveal-home-2809 .selected-work-home:not(.fa-in),
html.fa-reveal-home-2809 .about-sec:not(.fa-in),
html.fa-reveal-home-2809 .svc-sec:not(.fa-in),
html.fa-reveal-home-2809 .proc-sec:not(.fa-in),
html.fa-reveal-home-2809 .exp-sec:not(.fa-in),
html.fa-reveal-home-2809 .footer:not(.fa-in) {
  opacity: 0;
  transform: translate3d(0, var(--fa-home-block-y, 26px), 0);
}

html.fa-reveal-home-2809 .works-list > .work-row:not(.fa-in),
html.fa-reveal-home-2809 .about-wrap > *:not(.fa-in),
html.fa-reveal-home-2809 .svc-grid > .svc-card:not(.fa-in),
html.fa-reveal-home-2809 .proc-grid > .proc-step:not(.fa-in),
html.fa-reveal-home-2809 .proc-cta:not(.fa-in),
html.fa-reveal-home-2809 .exp-stats > .exp-stat:not(.fa-in),
html.fa-reveal-home-2809 .exp-rows > .exp-row:not(.fa-in),
html.fa-reveal-home-2809 .footer-cta:not(.fa-in),
html.fa-reveal-home-2809 .footer-bottom:not(.fa-in) {
  opacity: 0;
  transform: translate3d(0, var(--fa-home-item-y, 16px), 0);
}

/* ------------------------------------------------------------------ *
 * 3. KEADAAN TAMPIL — animasinya sendiri yang membawa keadaan awal
 *    (`backwards`), jadi jeda gelombangnya pun sudah tersembunyi, dan
 *    setiap animasi berakhir kembali di style elemennya sendiri.
 * ------------------------------------------------------------------ */

html.fa-reveal-home-2809 .marquee-container.fa-in,
html.fa-reveal-home-2809 .selected-work-home.fa-in,
html.fa-reveal-home-2809 .about-sec.fa-in,
html.fa-reveal-home-2809 .svc-sec.fa-in,
html.fa-reveal-home-2809 .proc-sec.fa-in,
html.fa-reveal-home-2809 .exp-sec.fa-in,
html.fa-reveal-home-2809 .footer.fa-in {
  animation: faHomeBlockIn2809 var(--fa-home-block-dur, 0.9s) cubic-bezier(0.19, 1, 0.22, 1) 0s 1 normal backwards running;
}

html.fa-reveal-home-2809 .works-list > .work-row.fa-in,
html.fa-reveal-home-2809 .about-wrap > .fa-in,
html.fa-reveal-home-2809 .svc-grid > .svc-card.fa-in,
html.fa-reveal-home-2809 .proc-grid > .proc-step.fa-in,
html.fa-reveal-home-2809 .proc-cta.fa-in,
html.fa-reveal-home-2809 .exp-stats > .exp-stat.fa-in,
html.fa-reveal-home-2809 .exp-rows > .exp-row.fa-in,
html.fa-reveal-home-2809 .footer-cta.fa-in,
html.fa-reveal-home-2809 .footer-bottom.fa-in {
  animation-name: faHomeItemIn2809;
  animation-duration: var(--fa-home-item-dur, 0.82s);
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: calc(var(--fa-home-item-wait, 140ms) + var(--fa-home-step, 0) * var(--fa-home-item-gap, 65ms));
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: backwards;
  animation-play-state: running;
}

/* ------------------------------------------------------------------ *
 * 4. REDUCED MOTION — reveal-nya tetap ada (isi tidak pernah disembunyikan),
 *    hanya jarak naiknya yang hilang: fade pendek saja.
 * ------------------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  html.fa-reveal-home-2809 {
    --fa-home-block-y: 0px;
    --fa-home-item-y: 0px;
    --fa-home-item-wait: 0ms;
    --fa-home-item-gap: 0ms;
    --fa-home-block-dur: 0.5s;
    --fa-home-item-dur: 0.5s;
  }
}


/* src/styles/about-entrance.css */
/* About page entrance — the home hero's reveal, on every load of /about
   ==================================================================
   REQUEST: "perbaiki animasi reveal di halaman about — semua text di halaman
   ini punya animasi reveal seperti hero halaman home, tapi saat halaman
   pertama di-load animasinya tidak bekerja sama sekali. Setiap kali halaman
   di-load harus ada text reveal yang sama dengan hero home. Jangan rubah
   header / halaman home."

   WHY IT COULD GO MISSING, AND WHAT THIS SHEET DOES
   ------------------------------------------------------------------
   Until now /about's entrance was split over two sheets and one script:
     · the header line rode src/styles/about-hero-entrance.css,
     · everything under it was armed on scroll by src/scripts/reveal.js.
   Both of them stand aside in the states a first load can land in — the
   script arms and waits for the viewport, and every sheet in the house
   switches itself off for visitors who prefer reduced motion. In those
   states the page simply appeared: no masked line, no rise, no reveal.

   This sheet gives /about ONE entrance that cannot go missing:
     · IT IS PURE CSS. No script, no class toggle, no viewport wait — so it
       re-runs on every single load of the page, by construction.
     · NOTHING CAN BE LEFT HIDDEN. The hiding comes only from the animation's
       own `from` state (`animation-fill-mode: backwards`), and every
       animation ends back on the element's own styles. If a rule here ever
       failed to apply, the copy would be plainly VISIBLE — never stranded
       behind an entrance that never comes.
     · IT IS THE HERO'S OWN VOCABULARY, re-used unchanged:
         faHeroLine   the masked line slide-up      (.fa-line > *)
         faHeroChip   blur + rise                   (labels, tags)
         faHeroRise   fade + rise                   (copy, paragraphs)
       and the hero's beat — the headline 180ms, its copy 520ms — continued
       down the page, one block after another.

   WHAT IT COVERS (the part of /about that is on screen when it loads)
     header    the "/ ABOUT ME" line            180ms  (masked slide)
               the subtitle                     520ms
     content   the portrait                     300ms
               "Hello, it's"     (.about-name)  420ms
               "Faisal Ahmad"    (.about-name)  520ms
               bio paragraph 1                  620ms
               bio paragraph 2                  700ms
               SERVICES & EXPERTISE             780ms  (blur + rise)
               the ten tags                     840ms → +55ms each
               the "View my work" button        1390ms  (the hero's CTA move)
   The footer of this page is deliberately NOT in the list: it sits below
   the fold on every breakpoint, so it keeps the scroll reveal it already
   had (src/scripts/reveal.js + src/styles/reveal.css).

   REVISION — the two name lines had lost their entrance entirely
   ------------------------------------------------------------------
   "Hello, it's" and "Faisal Ahmad" are <p class="about-name"> inside
   .about-name-mobile. This sheet still addressed them as .bio-heading — the
   class they carried when these beats were first written — so not one rule
   below ever matched them. Both halves of the entrance were therefore dead:
   the scroll reveal had already been stood down for that whole block
   (section 3), and the CSS entrance never applied, which is why the two
   lines simply appeared while everything around them was still landing.

   The selectors now name the element the page really carries:
     · the copy's rise — .aboutme .about-name (faHeroRise, as documented)
     · the beats       — .about-name-mobile's own children, in order
   The beat selectors deliberately address the wrapper's children rather
   than the class again, so a future rename of either class cannot silence
   this entrance a second time. Nothing else changed: the move (faHeroRise),
   the duration, the easing and the 420ms / 520ms beats are the ones this
   sheet has always documented — the two lines now ride the same rise as the
   portrait before them and the bio paragraphs after them, one block after
   another.

   TWO HOUSE RULES KEPT
     · ONLY opacity / transform / filter move — never a metric. No layout,
       no page height and no scroll position can change: this is a paint,
       not a shift.
     · NOTHING BELONGING TO ANOTHER PART IS TOUCHED: the mobile menu, the
       header chrome, the copy button and the cursor all keep their own
       behaviour, and no class or attribute is toggled at runtime.

   SCOPE — /about only, and only the two blocks named above. Every selector
   is rooted at .fa-about-hero (the page's header section) or .aboutme (its
   content section) — classes that no other page carries — so the header,
   the home page, /work and /contact are untouched.

   The shared sheet src/styles/about-hero-entrance.css still defines the
   header's mask. Its header beat is repeated here, with the same values and
   the same `!important`, so that this sheet — which loads last, priority
   230 > 220 — is the one that decides /about's entrance.

   MOTION PREFERENCE: the reveal below is intentionally not switched off for
   `prefers-reduced-motion`; the request was that the entrance be there on
   every load. To hand the decision back to the OS preference, append:
       @media (prefers-reduced-motion: reduce) {
         html body .fa-about-hero .fa-line > *,
         html body .fa-about-hero .page-subtitle,
         html body .aboutme .profile-wireframe-container,
         html body .aboutme .about-name,
         html body .aboutme .bio-p,
         html body .aboutme .skills-title,
         html body .aboutme .skill-tag,
         html body .aboutme .magnetic-btn-wrap { animation: none !important; }
       }
   ================================================================== */

/* ------------------------------------------------------------------ *
 * 1. the header — the hero's masked line, then its copy
 * ------------------------------------------------------------------ */

/* the travelling line: the same move, easing and beat as the hero's first
   line (faHeroLine 1.05s, 180ms in) */

html body .fa-about-hero .fa-line > * {
  animation-name: faHeroLine !important;
  animation-duration: 1.05s !important;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1) !important;
  animation-delay: 180ms !important;
  animation-iteration-count: 1 !important;
  animation-direction: normal !important;
  animation-fill-mode: backwards !important;
  animation-play-state: running !important;
}

/* the red slash is part of its own line — it travels with it, never on a
   beat of its own */

html body .fa-about-hero .fa-line .accent-text {
  animation: none !important;
  filter: none !important;
}

/* the subtitle follows on the hero's .hero-subtext beat */

html body .fa-about-hero .page-subtitle {
  animation-name: faHeroRise !important;
  animation-duration: 0.95s !important;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1) !important;
  animation-delay: 520ms !important;
  animation-iteration-count: 1 !important;
  animation-direction: normal !important;
  animation-fill-mode: backwards !important;
  animation-play-state: running !important;
}

/* ------------------------------------------------------------------ *
 * 2. the content — the portrait, then the page's own text
 * ------------------------------------------------------------------ */

/* the portrait — the one piece of media in the block, on its own beat just
   after the header has begun to move */

html body .aboutme .profile-wireframe-container {
  animation-name: faHeroRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 300ms;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* the copy — the name lines and the two bio paragraphs rise with the hero's
   own copy move (faHeroRise), one block after another */

html body .aboutme .about-name,
html body .aboutme .bio-heading,
html body .aboutme .bio-p {
  animation-name: faHeroRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* the labels — the section title and the tags arrive like the hero's chips:
   a short rise that sharpens out of a blur (faHeroChip) */

html body .aboutme .skills-title,
html body .aboutme .skill-tag {
  animation-name: faHeroChip;
  animation-duration: 0.9s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* THE ROW CARRIES ITS LABEL — THE LABEL NEVER RIDES A SECOND BEAT.
   ==================================================================
   REQUEST: "teks itu ada ditengah kemudian lompat kekiri setelah delay"
   — the reveal in this block is not clean: the label arrives out of line
   with its own star and then snaps into place.

   THE CAUSE — .skills-title is the class of BOTH the row and the label
   inside it:

       <div class="skills-title">        ← the row  (star + words)
         <svg class="marquee-star">
         <h3 class="skills-title">      ← the label (the same class again)
           Services & Expertise
         </h3>
       </div>

   so the rule above reached the label TWICE: once through the row that holds
   it, and once on its own account. Measured on this page at 1440 — the
   entrance's first frame, against the same block with its animations
   switched off:

       the row      +14px  (its own move)                  → lands at 0
       the star     +14px  (carried by the row)            → lands at 0
       the label    +28px  (the row's move PLUS its own)   → lands at 0
       the tags     +14px  (each one, on its own beat)     → land at 0

   The 14px the row contributes is the row carrying its child — that part is
   right. The other 14px is a second entrance nothing asked for: in the
   reveal the label sat a full 14px below its own star, travelled a longer
   distance than it, and snapped up into line when its beat ended; its blur
   was doubled on top of that (its own blur(7px) painted under the row's own
   blur(7px)), so it also arrived as a smear the tags never had.

   THE FIX — the label is not a second object, it is part of the row. A
   transform carries the whole subtree, so the row's move already takes the
   label with it, and the label's own beat is switched off here. Opacity
   included: the row's opacity multiplies the child's, so the label still
   fades in with the row, exactly once. This is the contract the home hero
   already uses for the red accent inside its title line: the LINE moves and
   everything inside it travels with it, never on a beat of its own.
   ================================================================== */

html body .fa-about-page .aboutme .skills-title > .skills-title {
  animation: none !important;
  opacity: 1 !important;
  filter: none !important;
  transform: none !important;
}

/* THE ROW'S OWN GEOMETRY, NAMED HERE SO IT CAN NOT BE TAKEN OVER.
   ==================================================================
   A second layer of styles — the sheets this site loads after the page's own
   CSS — was overriding this row's rhythm. Measured on this page: the row
   resolved to gap: 4px where its own CSS asks for 10px, i.e. the label sat
   6px to the LEFT of where the row wants it (star 20…50, label at 74
   instead of 80), and it stepped there the moment that layer landed: a
   visible left nudge a moment after the page opened, right on top of the
   entrance. With the row's gap named authoritatively the label comes back to
   80 — the same value the page's own CSS declares.

   So the row's rhythm and its alignment are stated here instead of inherited
   from whichever layer wins the cascade: the gap the page asks for, the row
   left-aligned (never centred) and the label centred against the star; and
   the tag row keeps the same left edge. While the layers agree — which is
   most of the time — every one of these is a no-op; when they do not, the
   first paint and the final paint stay the same paint.
   ================================================================== */

html body .fa-about-page .aboutme .skills-title {
  gap: 10px !important;
  justify-content: flex-start !important;
  align-items: center !important;
}

html body .fa-about-page .aboutme .skills-tags {
  justify-content: flex-start !important;
  row-gap: 10px !important;
  column-gap: 10px !important;
}

/* ---- the beats ----------------------------------------------------- */

html body .aboutme .about-name-mobile > *:nth-child(1) {
  animation-delay: 420ms;
}

html body .aboutme .about-name-mobile > *:nth-child(2) {
  animation-delay: 520ms;
}

html body .aboutme .bio-p:nth-child(1) {
  animation-delay: 620ms;
}

html body .aboutme .bio-p:nth-child(2) {
  animation-delay: 700ms;
}

html body .aboutme .skills-title {
  animation-delay: 780ms;
}

/* ------------------------------------------------------------------ *
 * THE BUTTON — THE LAST OBJECT IN THE BLOCK, AND THE ONE PIECE THAT HAD
 * NO ENTRANCE OF ITS OWN
 *
 * REQUEST: "button view my work di halaman about ini tidak punya animasi
 * reveal, coba berikan juga, samakan dengan style reveal seperti di tombol
 * hero home page."
 *
 * It was the only piece of this block that simply appeared: the copy rises,
 * the tags sharpen in one by one, and "View my work" was already there.
 * It now carries the hero's own call-to-action move, unchanged — faHeroRise,
 * 1s, cubic-bezier(0.19, 1, 0.22, 1) — which is the exact rule the home
 * hero puts on its own .magnetic-btn-wrap, only on this page's beat.
 *
 * THE BEAT IS THE ONE VALUE THAT DIFFERS: 1390ms, one stagger step (the
 * same 55ms the tags use) after the tenth tag, so the block keeps the "one
 * object after another" order this sheet documents instead of jumping back
 * into the middle of the tag run. Move, duration and easing are the hero's.
 *
 * THE WRAPPER IS ANIMATED — never the .magnetic-btn inside it. The cursor
 * script writes an inline transform on the button itself (the magnetic
 * pull) and an animation on transform would fight it; the wrapper has no
 * such owner, which is the same division of labour the hero's button uses.
 *
 * Only opacity and transform move: the wrapper keeps its margin, the button
 * keeps its size, and nothing in the layout can shift.
 * ------------------------------------------------------------------ */

html body .fa-about-page .aboutme .magnetic-btn-wrap {
  animation-name: faHeroRise;
  animation-duration: 1s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: 1390ms;
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* the tags, left to right: 840ms and 55ms apart — a long list gets the same
   tight stagger the hero's chips use, so it is never still running when the
   visitor is already reading it */

html body .aboutme .skills-tags .skill-tag:nth-child(1) {
  animation-delay: 840ms;
}

html body .aboutme .skills-tags .skill-tag:nth-child(2) {
  animation-delay: 895ms;
}

html body .aboutme .skills-tags .skill-tag:nth-child(3) {
  animation-delay: 950ms;
}

html body .aboutme .skills-tags .skill-tag:nth-child(4) {
  animation-delay: 1005ms;
}

html body .aboutme .skills-tags .skill-tag:nth-child(5) {
  animation-delay: 1060ms;
}

html body .aboutme .skills-tags .skill-tag:nth-child(6) {
  animation-delay: 1115ms;
}

html body .aboutme .skills-tags .skill-tag:nth-child(7) {
  animation-delay: 1170ms;
}

html body .aboutme .skills-tags .skill-tag:nth-child(8) {
  animation-delay: 1225ms;
}

html body .aboutme .skills-tags .skill-tag:nth-child(9) {
  animation-delay: 1280ms;
}

html body .aboutme .skills-tags .skill-tag:nth-child(10) {
  animation-delay: 1335ms;
}

/* a tag added later joins on the last beat instead of waiting */

html body .aboutme .skills-tags .skill-tag:nth-child(n + 11) {
  animation-delay: 1335ms;
}

/* ------------------------------------------------------------------ *
 * 3. the scroll reveal stands down inside these two blocks
 *
 *    src/scripts/reveal.js arms what it finds with .fa-reveal (opacity 0)
 *    and hands it back once it enters the viewport. On /about the entrance
 *    above owns the paint now, so those classes must not be able to hide
 *    anything this sheet has already revealed — a parent armed by the
 *    walker would otherwise hide the very text that is animating inside it.
 *    Neutralised here for this page's header and content only; the footer
 *    is not in the list and keeps its scroll reveal.
 *
 *    The scroll reveal keeps running as before: it still arms, reveals and
 *    releases exactly as it did — it simply cannot dim this page's copy.
 * ------------------------------------------------------------------ */

html body .fa-about-hero .fa-reveal,
html body .aboutme .fa-reveal {
  opacity: 1;
  transform: none;
  transition: none;
}

html body .fa-about-hero .fa-reveal.is-revealed,
html body .aboutme .fa-reveal.is-revealed {
  opacity: 1;
  transform: none;
}


/* src/styles/about-name-tablet.css */
/* The /about name — TWO lines on tablet
   ==================================================================
   REQUEST: "fokus ke element ini di breakpoint tablet, saya mau teks
   Hello, it's Faisal Ahmad menjadi dua baris, khusus ini tablet saja,
   desktop dan mobile jangan disentuh."

   THE ELEMENT — .about-name-tablet, the wrapper of the two name lines on
   /about's content section. It holds the pair as two children, one per line
   ("Hello, it's" / "Faisal Ahmad"), so the block reads as one line or as two
   purely from the wrapper's direction:

       desktop  (>769px)   ONE row  — the two lines sit side by side
       tablet   (≤769px)   TWO lines — the wrapper stacks them  ← this sheet
       mobile   (≤375px)   already TWO lines, and it stays exactly like that

   WHY THE BOUND IS 769px — the tablet frame lays out a hair ABOVE its nominal
   768px (768.18px — the measurement src/styles/footer.css and
   src/styles/button-size.css are both calibrated against), so a query written
   at max-width:768px never matches the very frame it describes and the two
   lines kept sitting side by side.

   WHY THERE IS NO LOWER BOUND — the phone needs no exception, and needs no
   rule of its own: the name is ALREADY one line above the other at every
   width ≤768px, so this declaration is a no-op there. Nothing on the phone
   moves, changes size or re-wraps; it is the tablet band alone that gains the
   stack. Desktop never matches the query at all.

   WHAT IS TOUCHED — flex-direction, and nothing else. Same type, same colour,
   same gap, same box: the entrance in src/styles/about-entrance.css still
   owns the paint of both lines (its beats address the wrapper's children, so
   a direction change cannot disturb them), and no other class, page or
   breakpoint is named here.
   ================================================================== */

@media (max-width: 769px) {
  html body .about-name-tablet {
    flex-direction: column;
  }
}


/* src/styles/work-entrance.css */
/* Work page entrance — the /about reveal, applied card by card
   ==================================================================
   REQUEST: "yang kamu baru kerjakan di halaman about adalah kerja bagus.
   Saya mau yang kamu terapkan di sana juga diterapkan di halaman work.
   Di sini animasi harus bekerja di card — animasi yang persis di halaman
   about — tapi untuk di work disesuaikan untuk card, karena di sini lebih
   banyak card dengan gambar, text-nya tentunya diberikan animasi yang sama
   juga."

   WHAT /about HAS, AND WHAT THIS SHEET GIVES /work
   ------------------------------------------------------------------
   /about's entrance (src/styles/about-entrance.css) is ONE vocabulary used
   on two levels: the header line rides faHeroLine, its copy rides
   faHeroRise, the labels ride faHeroChip — and every block down the page is
   given its own beat (portrait 300ms, name lines 420/520, bio 620/700,
   section title 780, the ten tags 840 + 55ms each).

   /work has that same header, and then eight cards instead of a column of
   copy — so the same vocabulary is applied at the two levels a card has:

     the card      faHeroRise  0.95s   the card rises as ONE object
                                       (0ms; the 2nd card of a row +110ms)
     the image     faHeroRise  0.95s   +120ms  the media leads, on the
                                       /about portrait's beat
     the year      faHeroChip  0.9s    +300ms  a label — it sharpens out of
                                       a blur, like /about's tags
     the name      faHeroRise  0.95s   +380ms
     the copy      faHeroRise  0.95s   +460ms
     the link      faHeroChip  0.9s    +540ms  a label again

   The inner beats keep /about's own step (80–100ms between blocks), simply
   compressed into the one object a card is; and a card that is already on
   screen when the page opens waits 300ms for the header's line, exactly the
   wait /about's portrait has. The same cubic-bezier(0.19, 1, 0.22, 1), the
   same 0.95s rise and the same `animation-fill-mode: backwards` are used
   throughout — nothing new is invented for this page.

   THE THREE STATES, AND WHY NOTHING CAN BE STRANDED
   ------------------------------------------------------------------
     [data-fa-work="on"]     armed — the animations exist but are held
       PAUSED. fill-mode `backwards` means the element waits in the
       animation's OWN first frame (opacity 0, 18px low): the hidden state is
       the animation itself, never a second class that could outlive it.
     .fa-work-in             played — the beat is running.
     [data-fa-work="done"]   released — both markers are gone and the card is
       back on its own styles, exactly as src/scripts/reveal.js hands an
       element back, so the hover zoom, the magnetic cursor and every other
       script find the DOM they expect.

   Every attribute and class above is added by src/scripts/work-entrance.js.
   With JavaScript off (or with reduced motion asked for) none of them
   exists, no animation is declared at all, and the grid is plainly visible.

   .fa-work-hero on the page's header section is the marker the script adds
   on /work only — the same idea as /about's .fa-about-hero — so nothing in
   this sheet can ever reach another route.

   TWO HOUSE RULES KEPT
     · only opacity / transform / filter move — never a metric, so a card's
       size, the grid's height and the scroll position never change;
     · nothing belonging to another part is touched: the card's hover zoom,
       its red hairline, the cursor, the header chrome and the footer keep
       their own behaviour.
   ================================================================== */

/* ------------------------------------------------------------------ *
 * 1. the header — exactly the entrance /about's header rides
 *    (.fa-work-hero is added by src/scripts/work-entrance.js on /work only)
 * ------------------------------------------------------------------ */

/* the mask is exactly as tall as its line — /about's header, same values */
html body .fa-work-hero .fa-line {
  display: block;
  overflow: hidden;
  height: auto !important;
  min-height: 0 !important;
  padding: 0 0 0.12em !important;
  margin: 0 0 -0.12em !important;
}

/* the travelling line: faHeroLine, the hero's first line, 180ms in */
html body .fa-work-hero .fa-line > * {
  margin: 0 !important;
  animation-name: faHeroLine !important;
  animation-duration: 1.05s !important;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1) !important;
  animation-delay: 180ms !important;
  animation-iteration-count: 1 !important;
  animation-direction: normal !important;
  animation-fill-mode: backwards !important;
  animation-play-state: running !important;
}

/* the red slash is part of its own line: it travels with it, it never blurs
   in on a beat of its own (the shared page-entrance sheet used to do that) */
html body .fa-work-hero .fa-line .accent-text {
  animation: none !important;
  filter: none !important;
}

/* the subtitle follows on the hero's .hero-subtext beat — 520ms, as /about */
html body .fa-work-hero .page-subtitle {
  animation-name: faHeroRise !important;
  animation-duration: 0.95s !important;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1) !important;
  animation-delay: 520ms !important;
  animation-iteration-count: 1 !important;
  animation-direction: normal !important;
  animation-fill-mode: backwards !important;
  animation-play-state: running !important;
}

/* ------------------------------------------------------------------ *
 * 2. the cards — armed, held, then played
 *
 *    --fa-work-card-delay is set per card by the script (the row stagger
 *    plus, for a card already on screen at load, the 300ms lead). Every
 *    animation below is declared once the card is armed and held PAUSED
 *    until .fa-work-in says the card has reached the viewport — the same
 *    "armed with a real animation, held, then played" contract the form
 *    rows use, and the shape of /about's entrance on scroll.
 * ------------------------------------------------------------------ */

html .work-grid .work-card[data-fa-work="on"],
html .work-grid .work-card[data-fa-work="on"] .work-card-bg,
html .work-grid .work-card[data-fa-work="on"] .work-card-number,
html .work-grid .work-card[data-fa-work="on"] .work-card-title,
html .work-grid .work-card[data-fa-work="on"] .work-card-desc,
html .work-grid .work-card[data-fa-work="on"] .work-card-link {
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: backwards;
  animation-play-state: paused;
  will-change: opacity, transform;
}

html .work-grid .work-card[data-fa-work="on"].fa-work-in,
html .work-grid .work-card[data-fa-work="on"].fa-work-in .work-card-bg,
html .work-grid .work-card[data-fa-work="on"].fa-work-in .work-card-number,
html .work-grid .work-card[data-fa-work="on"].fa-work-in .work-card-title,
html .work-grid .work-card[data-fa-work="on"].fa-work-in .work-card-desc,
html .work-grid .work-card[data-fa-work="on"].fa-work-in .work-card-link {
  animation-play-state: running;
}

/* the card itself — /about's copy move (faHeroRise 0.95s), one card after
   another so eight cards read as eight beats and never as one block */
html .work-grid .work-card[data-fa-work="on"] {
  animation-name: faHeroRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: var(--fa-work-card-delay, 0ms);
}

/* the screenshot — the card's media, first in, on /about's portrait beat */
html .work-grid .work-card[data-fa-work="on"] .work-card-bg {
  animation-name: faHeroRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: calc(var(--fa-work-card-delay, 0ms) + 120ms);
}

/* the year — a label, so it arrives like /about's tags (blur + rise) */
html .work-grid .work-card[data-fa-work="on"] .work-card-number {
  animation-name: faHeroChip;
  animation-duration: 0.9s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: calc(var(--fa-work-card-delay, 0ms) + 300ms);
}

/* the name, the description and the link — one block after another, the
   same beats /about gives its name lines, its paragraphs and its tags */
html .work-grid .work-card[data-fa-work="on"] .work-card-title {
  animation-name: faHeroRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: calc(var(--fa-work-card-delay, 0ms) + 380ms);
}

html .work-grid .work-card[data-fa-work="on"] .work-card-desc {
  animation-name: faHeroRise;
  animation-duration: 0.95s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: calc(var(--fa-work-card-delay, 0ms) + 460ms);
}

html .work-grid .work-card[data-fa-work="on"] .work-card-link {
  animation-name: faHeroChip;
  animation-duration: 0.9s;
  animation-timing-function: cubic-bezier(0.19, 1, 0.22, 1);
  animation-delay: calc(var(--fa-work-card-delay, 0ms) + 540ms);
}

/* ------------------------------------------------------------------ *
 * 3. the scroll reveal stands down inside the grid
 *
 *    src/scripts/reveal.js arms every .work-card with .fa-reveal (opacity
 *    0) and adds .is-revealed when the card enters the viewport. On /work
 *    the beat above owns the paint now, so those two classes must not be
 *    able to dim a card this sheet is animating — the same neutrality
 *    /about's sheet applies to its own two blocks.
 *
 *    Note that this also protects the failure case: if the script never
 *    armed anything (JS off, reduced motion, an error), a work card is
 *    simply VISIBLE instead of waiting behind a reveal that never comes.
 * ------------------------------------------------------------------ */

/* THE LIST IS NEUTRALISED AS WELL AS THE CARDS.
   The walker does not stop at a card: .work-list and .work-grid are
   containers it will happily arm on its own, and an armed CONTAINER lifts
   and dims every card inside it as one more transform on top of the beats
   this sheet writes — the cards then travel twice and land off their own
   rhythm. The list is part of the same block, so it is neutralised with the
   cards: the entrance above is the only thing that paints either of them. */

html .work-list.fa-reveal,
html .work-list.fa-reveal.is-revealed,
html .work-grid.fa-reveal,
html .work-grid.fa-reveal.is-revealed,
html .work-grid .work-card.fa-reveal,
html .work-grid .work-card.fa-reveal.is-revealed {
  opacity: 1;
  transform: none;
  transition: none;
}

/* ------------------------------------------------------------------ *
 * 4. reduced motion — nothing is armed, nothing moves
 *    (the script itself stands down before it marks a single card; these
 *    rules are the belt to that pair of braces)
 * ------------------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  html .work-grid .work-card[data-fa-work="on"],
  html .work-grid .work-card[data-fa-work="on"] .work-card-bg,
  html .work-grid .work-card[data-fa-work="on"] .work-card-number,
  html .work-grid .work-card[data-fa-work="on"] .work-card-title,
  html .work-grid .work-card[data-fa-work="on"] .work-card-desc,
  html .work-grid .work-card[data-fa-work="on"] .work-card-link {
    animation: none !important;
    opacity: 1;
    transform: none;
    will-change: auto;
  }

  html .work-grid .work-card.fa-reveal,
  html .work-grid .work-card.fa-reveal.is-revealed {
    opacity: 1 !important;
    transform: none !important;
  }

  html body .fa-work-hero .fa-line > *,
  html body .fa-work-hero .page-subtitle {
    animation: none !important;
    transform: none !important;
  }
}


/* src/styles/blog-reveal.css */
/* src/styles/blog-reveal.css
   BLOG LISTING — reveal untuk SETIAP kartu & elemen di dalamnya
   ==================================================================
   Pasangan: src/scripts/blog-reveal.js (pemicunya).

   Seluruh rule di file ini digantung pada flag `fa-blog-reveal` yang
   dipasang script di <html> SEBELUM paint pertama, dan elemen dibuka dengan
   class `fa-blog-in` yang script tambahkan begitu elemennya masuk viewport
   (IntersectionObserver). Karena state tersembunyi hanya ada di bawah
   `:not(.fa-blog-in)`, kegagalan apa pun berarti "tampil biasa", bukan
   "kosong / nyangkut tersembunyi".

   CARA MENONAKTIFKAN:
     1. atribut `data-fa-blog-reveal-off` di <html>, atau
     2. matikan script-nya di site.runtime → flag tidak pernah ada → semua
        rule di sini tidak berlaku: halaman tampil apa adanya.

   KOSAKATA — milik blog ini sendiri, berciri "blog":
       faBlogRise   fade + rise           (media / blok kartu)
       faBlogMask   masked line-reveal    (judul kartu)
       faBlogChip   blur + rise           (date, writer)

   Hanya opacity / transform / filter yang dianimasikan, jadi tidak ada yang
   menggeser layout. Setiap animasi berisi `backwards` dan berakhir di style
   asli elemennya — hover gambar, hover judul, dan reveal lain tetap utuh.
   ================================================================== */

/* --- tempo & jarak: satu sumber, diubah di sini saja ------------------- */
html.fa-blog-reveal {
  --fa-blog-t0: 120ms;         /* denyut pertama kartu pertama */
  --fa-blog-card-gap: 110ms;   /* jeda antar kartu */
  --fa-blog-step: 80ms;        /* jeda antar elemen dalam satu kartu */
  --fa-blog-rise: 18px;        /* jarak naik media & blok */
  --fa-blog-chip-rise: 12px;   /* jarak naik date / writer */
  --fa-blog-ease: cubic-bezier(0.19, 1, 0.22, 1);
}

/* --- gerakannya -------------------------------------------------------- */
@keyframes faBlogRise {
  from { opacity: 0; transform: translate3d(0, var(--fa-blog-rise, 18px), 0); }
  to   { opacity: 1; transform: translate3d(0, 0, 0); }
}

@keyframes faBlogMask {
  from { opacity: 0; transform: translate3d(0, 0.32em, 0); clip-path: inset(100% 0 0 0); }
  to   { opacity: 1; transform: translate3d(0, 0, 0); clip-path: inset(0 0 0 0); }
}

@keyframes faBlogChip {
  from { opacity: 0; transform: translate3d(0, var(--fa-blog-chip-rise, 12px), 0); filter: blur(7px); }
  to   { opacity: 1; transform: translate3d(0, 0, 0); filter: blur(0); }
}

/* --- state tersembunyi (sebelum masuk viewport) ------------------------ */
html.fa-blog-reveal .loop-container .blog-media-link:not(.fa-blog-in),
html.fa-blog-reveal .loop-container .blog-heading:not(.fa-blog-in),
html.fa-blog-reveal .loop-container .blog-date:not(.fa-blog-in),
html.fa-blog-reveal .loop-container .blog-writer:not(.fa-blog-in) {
  opacity: 0;
  transform: translate3d(0, var(--fa-blog-rise, 18px), 0);
}

/* --- dibuka (script menambahkan .fa-blog-in) --------------------------- */

/* media kartu — naik + fade, duluan di tiap kartu */
html.fa-blog-reveal .loop-container .blog-media-link.fa-blog-in {
  animation-name: faBlogRise;
  animation-duration: 0.95s;
  animation-timing-function: var(--fa-blog-ease);
  animation-delay: calc(var(--fa-blog-t0, 120ms) + var(--fa-blog-card, 0) * var(--fa-blog-card-gap, 110ms) + var(--fa-blog-inner, 0) * var(--fa-blog-step, 80ms));
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* judul kartu — masked line, menyusul satu beat setelah media */
html.fa-blog-reveal .loop-container .blog-heading.fa-blog-in {
  animation-name: faBlogMask;
  animation-duration: 0.95s;
  animation-timing-function: var(--fa-blog-ease);
  animation-delay: calc(var(--fa-blog-t0, 120ms) + var(--fa-blog-card, 0) * var(--fa-blog-card-gap, 110ms) + var(--fa-blog-inner, 0) * var(--fa-blog-step, 80ms));
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* date & writer — chip: naik pendek sambil menajam keluar dari blur */
html.fa-blog-reveal .loop-container .blog-date.fa-blog-in,
html.fa-blog-reveal .loop-container .blog-writer.fa-blog-in {
  animation-name: faBlogChip;
  animation-duration: 0.8s;
  animation-timing-function: var(--fa-blog-ease);
  animation-delay: calc(var(--fa-blog-t0, 120ms) + var(--fa-blog-card, 0) * var(--fa-blog-card-gap, 110ms) + var(--fa-blog-inner, 0) * var(--fa-blog-step, 80ms));
  animation-iteration-count: 1;
  animation-fill-mode: backwards;
}

/* --- preferensi gerak: reveal tetap ada, terbangnya yang dikecilkan ----- */
@media (prefers-reduced-motion: reduce) {
  html.fa-blog-reveal .loop-container .blog-media-link:not(.fa-blog-in),
  html.fa-blog-reveal .loop-container .blog-heading:not(.fa-blog-in),
  html.fa-blog-reveal .loop-container .blog-date:not(.fa-blog-in),
  html.fa-blog-reveal .loop-container .blog-writer:not(.fa-blog-in) {
    opacity: 0;
    transform: none;
  }

  html.fa-blog-reveal .loop-container .fa-blog-in {
    animation-name: none;
  }
}


/* src/styles/post-reveal-0210.css */
/* src/styles/post-reveal-0210.css
   POST REVEAL — ditulis 02 Oktober 2026 (0210)
   ==================================================================
   PASANGAN — dua file bertanggal sama (0210), supaya gampang dicari:
       src/styles/post-reveal-0210.css   ← file ini       (gerakannya)
       src/scripts/post-reveal-0210.js   ← pasangannya    (peng-arm-nya)

   CARA MENONAKTIFKAN
       1. tambahkan atribut `data-fa-post-reveal-off` di <html> — script
          menyerah sebelum menyentuh apa pun;
       2. atau matikan script-nya di site.runtime — flag
          `fa-post-reveal-0210` tidak pernah dipasang, jadi PEGANGAN-nya
          (bagian 3b) tidak pernah berlaku dan denyutnya mulai lebih awal;
          gerakannya sendiri TETAP jalan, karena setiap rule di sini juga
          digantung pada halaman yang memuat .post-style (lihat REVISI
          "ANIMASINYA HILANG DI HALAMAN INI");
       3. atau hapus pasangan file-nya sekaligus.
     Karena itu "script mati" selalu berarti "halaman tetap ber-reveal",
     tidak pernah "halaman kosong" dan tidak pernah "halaman tanpa gerakan".

   KOSAKATA REVEAL
       .post-img           tirai clip-path dari bawah — fotonya terungkap
       .post-img img       scale 1.12 → 1, menetap di balik tirai itu
       .post-cat           chip: blur + naik
       .post-heading       masked line: clip + naik
       .name-contributor   masked line
       .post-date          fade + naik
       .post-share::after  hairline digambar dari kiri (scaleX)
       .share-icon ×5      chip berurutan (--fa-post-i diisi script)

   TEMPO — satu gelombang dari kiri ke kanan, seluruhnya dari variabel di
   bagian 1 (foto → kategori → judul → kontributor → tanggal → garis →
   ikon-ikonnya):
       --fa-post-t0    380ms   foto mulai (dulu 320ms — lihat REVISI)
       --fa-post-step   90ms   jeda antar langkah
       --fa-post-gap    55ms   jeda antar ikon share
   Denyutnya sengaja mulai sedikit setelah header (dipimpin
   src/styles/header-intro.css: logo 280ms, menu 150–315ms), lalu mengalir
   ke kanan sampai ikon share terakhir di ~1,9s.

   REVISI — "GAMBARNYA PATAH DI AKHIR REVEAL"
   ------------------------------------------------------------------
   Keluhan: "gambar ketika reveal animasi selesai tidak langsung muncul,
   hasilnya gambar keluar mendadak." Gerakannya tidak salah — WAKTUNYA yang
   tidak sinkron: tirainya selesai membuka (t0 + 1,1s) sementara bitmap
   fotonya masih di jalan, jadi yang terlihat lebih dulu adalah tirai yang
   membuka ke ruang kosong, lalu gambarnya menyusul "patah" begitu unduhan
   mendarat.

   Dua perubahan, tidak lebih:
     1. REVEAL-NYA MENUNGGU GAMBARNYA. Script pasangannya memasang
        `fa-post-reveal-0210-hold` di <html> sebelum paint pertama; selama
        class itu ada SEMUA animasi di berkas ini di-pause (bagian 3b), jadi
        denyutnya baru mulai setelah fotonya benar-benar ter-decode. Yang
        di-pause tetap memegang keadaan `from`-nya, dan pegangannya selalu
        dilepas (batas 1800ms di script-nya) — jadi tidak ada yang bisa
        tersangkut tersembunyi.
     2. FOTONYA "DICUCI KELUAR", BUKAN DITEMBAK. faPostSettle ikut membawa
        opacity dan durasinya dipanjangkan, jadi bitmap yang datang sedikit
        terlambat pun masuk sebagai bagian dari gerakannya, bukan sebagai
        lompatan satu frame. (Opacity-nya sejak revisi berikutnya dipisah ke
        animasinya sendiri, dan titik 60% itu dilepas lagi.)

   Tempo yang ikut disesuaikan (bagian 1): t0 320ms → 380ms, tirai 1,1s →
   1,45s, foto 1,6s → 2,3s. Tidak ada elemen, denyut, atau berkas lain yang
   berubah: seluruh berkas ini tetap hanya berlaku di halaman yang memuat
   .post-style, jadi tidak ada halaman lain yang bisa mengenainya.

   REVISI — "SENTAKAN KECIL TEPAT SEBELUM REVEAL GAMBARNYA BERHENTI"
   ------------------------------------------------------------------
   Keluhan: "animasi reveal gambarnya sudah tepat, tapi ada lompatan aneh
   kecil ketika gambarnya selesai — mulai sesaat sebelum animasinya
   berhenti."

   Penyebabnya bukan tempo dan bukan gambarnya, melainkan BENTUK kurvanya.
   faPostSettle dulu punya titik tengah di 60% (opacity sudah 1 di sana,
   scale masih 1.045). Satu `animation-timing-function` berlaku PER SEGMEN
   — dari keyframe ke keyframe, bukan dari awal ke akhir — jadi di titik 60%
   kecepatan gambarnya di-reset: segmen pertama (scale 1.12 → 1.045)
   mengendur lalu berhenti, dan segmen kedua (1.045 → 1) langsung menyentak
   naik lagi sebelum mengendur ke tempatnya. Itulah hentakan kecil yang
   terasa sesaat sebelum gambarnya berhenti; gerakannya jadi terbaca "dua
   kali": cepat, mengendur, lalu menyentak.

   Dua perubahan, tidak lebih:
     1. faPostSettle SEKARANG SATU BENTANG PENUH — scale 1.12 → 1, tanpa
        keyframe di tengah. Kecepatannya mengalir dari awal ke akhir tanpa
        satu pun titik yang bisa di-reset. Tempo, easing dan durasinya tidak
        diubah: kurvanya saja yang sekarang satu tarikan.
     2. OPACITY-NYA DIPISAH ke animasinya sendiri — faPostFadeIn, satu
        bentang 0 → 1, t0 + 1,1s (`--fa-post-fade-dur`). Maksud revisi
        sebelumnya tetap dipegang: bitmap fotonya tidak pernah muncul dalam
        satu frame. Karena fade-nya selesai jauh sebelum tirai selesai
        membuka (t0 + 1,45s), yang terungkap selalu gambar yang sudah utuh —
        dan opacity memang boleh berhenti lebih dulu, dia bukan gerakan.

   Silang periksa cepat: pada frame tirai selesai membuka (t0 + 1,45s)
   kurva baru ini sudah ~99,8% selesai, jadi gambarnya SUDAH diam tepat saat
   tirainya mendarat — tidak ada lagi gerakan yang menyusul setelah
   reveal-nya dinyatakan selesai.

   Tidak ada selector, elemen, tempo, keyframes lain, media query, atau blok
   reduced-motion yang berubah; dan seluruh berkas ini tetap hanya berlaku di
   halaman yang memuat .post-style — halaman lain tidak bisa mengenainya.

   REVISI — "ANIMASINYA HILANG DI HALAMAN INI"
   ------------------------------------------------------------------
   Keluhan: "malah hilang animasi di halaman ini."

   Sampai revisi ini seluruh berkas digantung pada SATU class di <html> yang
   dipasang script pasangannya (`fa-post-reveal-0210`). Itu satu titik gagal:
   begitu class itu tidak ada — script-nya tidak sempat jalan, atau jaring
   pengaman di script itu mencabutnya terlalu cepat — tidak satu pun rule di
   sini berlaku, dan halaman hanya tampil biasa tanpa satu pun jejak reveal.
   Yang salah bukan gerakannya, melainkan PINTU MASUKNYA yang terlalu
   bergantung pada script.

   Sekarang pintunya punya dua jalan:

       html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) …

     · `:has(.post-style)` — halaman yang memuat reveal ini mengenali
       DIRINYA SENDIRI, jadi animasinya tetap ada walau script pasangannya
       tidak jalan sama sekali;
     · `.fa-post-reveal-0210` — flag dari script-nya tetap berlaku seperti
       dulu, dan itu yang masih memegang PEGANGAN (bagian 3b): selama
       flag-nya ada, script boleh menahan denyutnya sampai fotonya siap.
   `:where()` dipakai supaya spesifisitasnya TIDAK berubah — tetap (0,3,1),
   sama seperti sebelumnya — sehingga rule PEGANGAN (0,4,1) tetap yang menang
   saat ia menulis `animation-play-state: paused`.

   Mematikannya tetap satu langkah: atribut `data-fa-post-reveal-off` di
   <html>. Mematikan script-nya sekarang tidak lagi mematikan animasinya;
   yang hilang hanya pegangannya, jadi denyutnya mulai lebih awal.

   ATURAN RUMAH YANG DIPEGANG
     · HANYA opacity / transform / filter / clip-path yang dianimasikan —
       tidak ada satu pun ukuran (width, height, padding, gap) yang berubah,
       jadi tinggi halaman dan posisi scroll saat first paint tidak bergeser.
     · TIDAK ADA TRAVEL DOBEL. Elemen yang dianimasikan di sini tidak punya
       anak yang dianimasikan lagi — dengan dua pengecualian yang disengaja:
       (a) fotonya: tirai induknya hanya memakai clip-path, tidak transform,
           jadi settle di gambar di dalamnya bergerak bersih;
       (b) garis share: ia digambar oleh ::after .post-share, BUKAN oleh
           .post-share sendiri — kalau induknya yang di-scaleX, kelima ikon
           di dalamnya ikut terjepit.
     · HEADER & FOOTER TIDAK DISENTUH. Keduanya milik template "everywhere"
       dan entrance-nya sudah punya pemilik sendiri.
     · TIDAK ADA YANG BISA TERSANGKUT TERSEMBUNYI. Keadaan tersembunyi hanya
       datang dari `from` animasinya sendiri (animation-fill-mode: backwards)
       dan setiap animasi berakhir kembali di style elemennya; tidak ada
       aturan "display/opacity: 0 tanpa flag" di sini. Kalau satu rule gagal
       berlaku, isinya hanya TERLIHAT BIASA — bukan hilang.
   ================================================================== */


/* ------------------------------------------------------------------ *
 * 1. TEMPO — satu tempat untuk seluruh denyut
 * ------------------------------------------------------------------ */

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) {
  --fa-post-t0: 380ms;
  --fa-post-step: 90ms;
  --fa-post-gap: 55ms;
  --fa-post-rise: 18px;
  --fa-post-img-dur: 1.45s;      /* tirai — dulu 1.1s, lihat REVISI */
  --fa-post-settle-dur: 2.3s;    /* foto "dicuci keluar" — dulu 1.6s */
  --fa-post-fade-dur: 1.1s;      /* opacity fotonya, animasi terpisah — lihat REVISI "SENTAKAN" */
  --fa-post-line-dur: 1.05s;
  --fa-post-rise-dur: 0.9s;
  --fa-post-chip-dur: 0.85s;
  --fa-post-draw-dur: 1.1s;
  --fa-post-ease: cubic-bezier(0.19, 1, 0.22, 1);
}


/* ------------------------------------------------------------------ *
 * 2. KEYFRAMES
 * ------------------------------------------------------------------ */

/* fotonya — tirai yang terbuka dari bawah */
@keyframes faPostWipe {
  from {
    clip-path: inset(0 0 100% 0);
  }

  to {
    clip-path: inset(0 0 0 0);
  }
}

/* dan gambarnya menetap sedikit lagi setelah tirai terbuka.

   SATU BENTANG, TANPA TITIK TENGAH — lihat REVISI "SENTAKAN KECIL TEPAT
   SEBELUM REVEAL GAMBARNYA BERHENTI" di kepala berkas. Titik 60% yang dulu
   berdiri di sini dihapus: `animation-timing-function` berlaku PER SEGMEN
   (dari keyframe ke keyframe, bukan dari awal ke akhir), jadi di titik itu
   kecepatannya di-reset — segmen pertama mengendur sampai berhenti, segmen
   kedua menyentak naik lagi. Itulah hentakan kecilnya. Sekarang 0% → 100%
   satu tarikan, jadi tidak ada titik mana pun yang bisa tersentak. */
@keyframes faPostSettle {
  from {
    transform: scale(1.12);
  }

  to {
    transform: scale(1);
  }
}

/* opacity fotonya — animasi TERPISAH, supaya tidak perlu ada titik tengah
   yang membagi gerakannya: satu bentang 0 → 1, dan selesai jauh sebelum
   tirainya selesai membuka (t0 + 1,1s melawan t0 + 1,45s), jadi yang
   terungkap selalu gambar yang sudah utuh. */
@keyframes faPostFadeIn {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

/* masked line — dipakai judul & nama kontributor */
@keyframes faPostMask {
  from {
    opacity: 0;
    transform: translate3d(0, 0.34em, 0);
    clip-path: inset(100% 0 0 0);
  }

  to {
    opacity: 1;
    transform: translate3d(0, 0, 0);
    clip-path: inset(0 0 0 0);
  }
}

/* fade + naik — baris yang tidak perlu topeng */
@keyframes faPostRise {
  from {
    opacity: 0;
    transform: translate3d(0, var(--fa-post-rise, 18px), 0);
  }

  to {
    opacity: 1;
    transform: translate3d(0, 0, 0);
  }
}

/* chip — naik pendek sambil menajam keluar dari blur */
@keyframes faPostChip {
  from {
    opacity: 0;
    transform: translate3d(0, 12px, 0);
    filter: blur(8px);
  }

  to {
    opacity: 1;
    transform: translate3d(0, 0, 0);
    filter: blur(0);
  }
}

/* garis hairline — digambar dari kiri */
@keyframes faPostDraw {
  from {
    opacity: 0;
    transform: scaleX(0);
  }

  to {
    opacity: 1;
    transform: scaleX(1);
  }
}

/* dipakai blok reduced-motion: reveal-nya tetap ada, jaraknya yang hilang */
@keyframes faPostFade {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}


/* ------------------------------------------------------------------ *
 * 3. KOREOGRAFINYA
 * ------------------------------------------------------------------ */

/* 3.1 FOTO — tirai, lalu gambarnya menetap di baliknya.
   Tirai induknya TIDAK memakai transform supaya settle di gambarnya bersih
   (satu elemen, satu gerakan). */

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-img {
  animation-name: faPostWipe;
  animation-duration: var(--fa-post-img-dur, 1.45s);
  animation-timing-function: var(--fa-post-ease);
  animation-delay: var(--fa-post-t0, 320ms);
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: backwards;
  animation-play-state: running;
  will-change: clip-path;
}

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-img img {
  /* DUA ANIMASI, DUA SIFAT — keduanya satu bentang penuh, dan itu memang
     tujuannya (lihat REVISI "SENTAKAN KECIL TEPAT SEBELUM REVEAL GAMBARNYA
     BERHENTI" di kepala berkas). Scale tetap gerakan utamanya dan berjalan
     utuh sampai akhir; fade opacity-nya cuma menemani di awal — opacity
     bukan gerakan, jadi dia boleh berhenti lebih dulu tanpa terlihat
     tersentak. Kedua daftar di bawah selalu sejajar panjangnya. */
  animation-name: faPostSettle, faPostFadeIn;
  animation-duration: var(--fa-post-settle-dur, 2.3s), var(--fa-post-fade-dur, 1.1s);
  animation-timing-function: var(--fa-post-ease), ease-out;
  animation-delay: var(--fa-post-t0, 380ms), var(--fa-post-t0, 380ms);
  animation-iteration-count: 1, 1;
  animation-direction: normal, normal;
  animation-fill-mode: backwards, backwards;
  animation-play-state: running, running;
  will-change: transform, opacity;
}

/* 3.2 KATEGORI — label kecil di atas judul, datang seperti chip */

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-cat {
  animation-name: faPostChip;
  animation-duration: var(--fa-post-chip-dur, 0.85s);
  animation-timing-function: var(--fa-post-ease);
  animation-delay: calc(var(--fa-post-t0, 320ms) + var(--fa-post-step, 90ms));
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: backwards;
  animation-play-state: running;
}

/* 3.3 JUDUL — garisnya sendiri yang jadi topeng:

   clip-path inset(100% 0 0 0) menutup seluruh kotaknya, lalu membuka sambil
   barisnya naik 0.34em — jadi judulnya terbaca keluar dari topengnya, bukan
   sekadar muncul. Animasi ini berakhir tanpa `forwards`, jadi clip-nya
   dilepas kembali begitu selesai dan judul kembali 100% utuh (termasuk
   descender huruf seperti g/j/y). */

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-heading {
  animation-name: faPostMask;
  animation-duration: var(--fa-post-line-dur, 1.05s);
  animation-timing-function: var(--fa-post-ease);
  animation-delay: calc(var(--fa-post-t0, 320ms) + var(--fa-post-step, 90ms) * 2);
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: backwards;
  animation-play-state: running;
  will-change: clip-path, transform;
}

/* 3.4 KONTRIBUTOR — topeng yang sama, satu beat kemudian */

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .name-contributor {
  animation-name: faPostMask;
  animation-duration: var(--fa-post-line-dur, 1.05s);
  animation-timing-function: var(--fa-post-ease);
  animation-delay: calc(var(--fa-post-t0, 320ms) + var(--fa-post-step, 90ms) * 3);
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: backwards;
  animation-play-state: running;
  will-change: clip-path, transform;
}

/* 3.5 TANGGAL — tidak perlu topeng, cukup naik */

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-date {
  animation-name: faPostRise;
  animation-duration: var(--fa-post-rise-dur, 0.9s);
  animation-timing-function: var(--fa-post-ease);
  animation-delay: calc(var(--fa-post-t0, 320ms) + var(--fa-post-step, 90ms) * 4);
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: backwards;
  animation-play-state: running;
}

/* 3.6 GARIS SHARE — digambar dari kiri.

   Garis aslinya adalah border-top milik .post-share. Di bawah flag-nya
   border itu dibuat transparan dan ::after-lah yang menggambarnya, supaya
   scaleX-nya tidak pernah menyentuh kelima ikon yang duduk di dalam
   elemen yang sama. Posisi -1px menaruhnya persis di tempat border tadi. */

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-share {
  border-top-color: transparent;
}

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-share::after {
  content: "";
  position: absolute;
  top: -1px;
  left: 0;
  width: 100%;
  height: 1px;
  background-color: currentcolor;
  transform-origin: left center;
  animation-name: faPostDraw;
  animation-duration: var(--fa-post-draw-dur, 1.1s);
  animation-timing-function: var(--fa-post-ease);
  animation-delay: calc(var(--fa-post-t0, 320ms) + var(--fa-post-step, 90ms) * 5);
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: backwards;
  animation-play-state: running;
  will-change: transform;
}

/* 3.7 IKON SHARE — satu per satu, mengikuti garisnya */

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .share-icon {
  animation-name: faPostChip;
  animation-duration: var(--fa-post-chip-dur, 0.85s);
  animation-timing-function: var(--fa-post-ease);
  animation-delay: calc(var(--fa-post-t0, 320ms) + var(--fa-post-step, 90ms) * 6 + var(--fa-post-i, 0) * var(--fa-post-gap, 55ms));
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: backwards;
  animation-play-state: running;
  will-change: transform, filter;
}

/* NOMOR CADANGAN. Script pasangannya mengisi --fa-post-i secara inline
   (inline selalu menang), tapi nomornya juga berdiri di sini — jadi stagger-
   nya tetap berurutan walau script-nya tidak sempat mengisi. */

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .share-icon:nth-child(1) {
  --fa-post-i: 0;
}

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .share-icon:nth-child(2) {
  --fa-post-i: 1;
}

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .share-icon:nth-child(3) {
  --fa-post-i: 2;
}

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .share-icon:nth-child(4) {
  --fa-post-i: 3;
}

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .share-icon:nth-child(5) {
  --fa-post-i: 4;
}

html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .share-icon:nth-child(6) {
  --fa-post-i: 5;
}


/* ------------------------------------------------------------------ *
 * 3b. PEGANGAN (HOLD) — reveal-nya menunggu gambarnya siap
 *
 *     Script pasangannya memasang `fa-post-reveal-0210-hold` di <html>
 *     SEBELUM paint pertama, lalu melepasnya lagi begitu bitmap fotonya
 *     benar-benar sudah ter-decode — dan paling lambat 1800ms setelah
 *     dipasang, apa pun yang terjadi.
 *
 *     Selama class itu ada, animasi di bawah ini DI-PAUSE, bukan dihapus:
 *     yang di-pause masih memegang keadaan `from`-nya (fill-mode: backwards),
 *     jadi isinya tetap tersembunyi sampai denyutnya benar-benar mulai, dan
 *     begitu pegangannya lepas, gerakannya berjalan utuh dari awalnya — tanpa
 *     lompatan dan tanpa satu frame pun yang terlihat separuh.
 *
 *     Kenapa perlu: tirainya selesai membuka di t0 + 1,45s. Kalau bitmap
 *     fotonya belum siap pada saat itu, yang terlihat adalah tirai yang
 *     membuka ke ruang kosong — lalu gambarnya menyusul mendadak begitu
 *     unduhannya mendarat. Itulah "gambar patah" yang diperbaiki revisi ini.
 *
 *     Selector-nya sengaja membawa dua class di <html> (0,4,1) supaya
 *     mengalahkan `animation-play-state: running` yang ditulis rule aslinya
 *     (0,3,1) — dan hanya rule ini yang menyentuh play-state.
 * ------------------------------------------------------------------ */

html.fa-post-reveal-0210.fa-post-reveal-0210-hold .post-style .post-img,
html.fa-post-reveal-0210.fa-post-reveal-0210-hold .post-style .post-img img,
html.fa-post-reveal-0210.fa-post-reveal-0210-hold .post-style .post-cat,
html.fa-post-reveal-0210.fa-post-reveal-0210-hold .post-style .post-heading,
html.fa-post-reveal-0210.fa-post-reveal-0210-hold .post-style .name-contributor,
html.fa-post-reveal-0210.fa-post-reveal-0210-hold .post-style .post-date,
html.fa-post-reveal-0210.fa-post-reveal-0210-hold .post-style .post-share::after,
html.fa-post-reveal-0210.fa-post-reveal-0210-hold .post-style .share-icon {
  animation-play-state: paused;
}


/* ------------------------------------------------------------------ *
 * 4. TABLET & MOBILE — gelombang yang sama, satu tarikan lebih pendek
 * ------------------------------------------------------------------ */

@media (max-width: 768px) {
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) {
    --fa-post-t0: 200ms;
    --fa-post-step: 70ms;
    --fa-post-gap: 40ms;
    --fa-post-rise: 14px;
  }
}

@media (max-width: 375px) {
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) {
    --fa-post-t0: 170ms;
    --fa-post-step: 60ms;
    --fa-post-gap: 34ms;
    --fa-post-rise: 12px;
  }
}


/* ------------------------------------------------------------------ *
 * 5. PREFERENSI GERAK — reveal-nya tetap ada, jaraknya yang hilang
 *    (fade tetap sebuah reveal; tirai dan terbangnya tidak)
 * ------------------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) {
    --fa-post-rise: 0px;
    --fa-post-gap: 0ms;
  }

  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-img,
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-heading,
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .name-contributor,
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .share-icon,
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-share::after {
    animation-name: faPostFade;
  }

  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-img img {
    animation-name: none;
  }
}

/* OPT-OUT — mengembalikan seluruh keputusan ke preferensi OS: cukup
   uncomment blok di bawah ini.

@media (prefers-reduced-motion: reduce) {
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-img,
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-img img,
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-cat,
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-heading,
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .name-contributor,
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-date,
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .post-share::after,
  html:where(:not([data-fa-post-reveal-off])):is(.fa-post-reveal-0210, :has(.post-style)) .post-style .share-icon {
    animation: none;
  }
}
*/


/* src/styles/home-entrance.css */
/* Home hero — ONE owner for the paint, and the block's own rhythm named here
   ==================================================================
   The home hero is the block whose entrance lives in the PAGE's own CSS (the
   faHero* beats: badge 100ms · title line 180ms · location chip 220ms · the
   line under the title 310ms · copy 520ms · image 560ms · button 700ms). The
   rest of the house has since been given the contract below —
   src/styles/about-entrance.css, src/styles/work-entrance.css,
   src/styles/hero-line-mask.css, src/styles/hero-img-entrance.css,
   src/styles/page-entrance.css — and this sheet is that contract for the
   hero: the walker stands down, EVERY beat of the hero is named here where a
   later layer can not take it over, the text metrics the masked lines are
   MEASURED from are stated here as well, and the entrance is there on every
   load.

   REVISION — THE HERO HAD FOUR OWNERS, AND ONE OF THEM WAS SWITCHED OFF
   ------------------------------------------------------------------
   The reported faults on the home page — a line that appeared inside the
   middle of its mask and then jumped, elements with no entrance at all, and
   a rhythm that read as wrong — all came from the same shape: the hero's
   entrance declared more than once, by layers that did not agree, and (for
   one preference) switched off everywhere.

   1. THE MASKED TITLE LINES WERE DECLARED TWICE, AND THE TWO DECLARATIONS
      DISAGREED. The page's own CSS handed the line faHeroLine — 118% of the
      LINE'S OWN height, which is not enough to clear a clip box that holds
      the title line PLUS the line under it — at 180ms for BOTH lines, while
      src/styles/hero-line-mask.css handed the same two spans faHeroLineClear
      (100% + 2em) at 180ms / 310ms. So the first paint showed the bottom of
      the title inside the mask, and the moment the other layer landed the
      running animation was REPLACED: the line restarted, travelled a
      different distance and snapped into place. The lines' own entrance is
      stated in this sheet now (the same move, the same two beats), and the
      page's own declarations were brought onto exactly the same values, so
      the two layers can no longer disagree and the first paint IS the final
      paint.

   2. THE HERO IMAGE'S BOX RODE THE SITE'S LOWEST-PRIORITY SHEET
      (src/styles/hero-img-entrance.css) and its <img> carries the very same
      .hero-img class as that box, so a second entrance could be written onto
      the picture on top of the one its own box was travelling. The box's
      beat is stated here now, next to the guard that keeps the picture on
      its box's move.

   3. THE BEATS WERE SWITCHED OFF FOR prefers-reduced-motion IN EVERY LAYER
      — the page's own CSS, this sheet and the mask sheet — so on a machine
      whose OS asks for less motion the home hero had NO entrance at all,
      while /about deliberately keeps its own (src/styles/about-entrance.css,
      "the entrance is on every load"). The hero keeps its entrance on every
      load now, exactly as /about does: the beats below are authoritative,
      including over the page-level reduced-motion guards. The block that
      hands the decision back to the OS preference is at the end of this
      file.

   THE FOUR RULES THIS SHEET OBEYS  (the /about + /work contract)
   ------------------------------------------------------------------
   1. ONE PAINT OWNER. The hero's own beats paint every block inside the
      hero, so nothing else may hide, lift, re-time or re-measure one.

      · .hero-section is on reveal.js's SKIP list (as .page-header is), and
        the armed classes are neutralised here as well — for the failure case
        that list can not cover: a .fa-reveal written into the document.
      · THE HERO IMAGE CARRIES THE SAME CLASS ON ITS BOX AND ON THE <img>
        INSIDE IT — the site's one duplicated class on a wrapper and its
        child. The BOX is the owner: its transform carries the whole subtree,
        so an entrance on the <img> could only double the travel.

   2. THE BLOCK'S OWN RHYTHM AND METRICS, NAMED HERE. The masked line's move
      travels 100% of the LINE'S OWN HEIGHT plus 2em of its own font-size,
      and the clip box is the title line plus the line under it. So a metric
      that a later layer re-decides — a swapped webfont, a breakpoint class,
      a runtime style — does not only change the type: it changes the
      distance the line travels, WHILE it is travelling, and the line lands
      off its own beat. The values the page finally shows are therefore
      declared here, at every breakpoint the site ships, with the authority
      to hold them:

        desktop   section 160/60 · text column gap --space-1-xs ·
                  title 60px · the line under it 2rem/1.2 · copy 1rem/1.2 ·
                  pills .85rem/1.6, 8px 16px
        tablet    section padding SIDES 30px · text column gap --space-1-s ·
                  title 40px · the line under it 20px/1.5 · copy line 1.5 ·
                  meta row stacked, gap 10px
        phone     pill labels 12px (the site's own phone value)

      The bounds carry the house's half-pixel epsilon (768.5 / 375.5): a
      breakpoint frame lays out a hair ABOVE its nominal width (the tablet
      frame measures 768.18px), so a query written at 768px never matches the
      very frame it describes.

      THE PAGE'S OWN CSS STATES THE SAME SIDES at ≤768 now (it used to ask
      for 60px left / 0 right, which is why the whole hero stepped 30px
      sideways the moment this sheet landed).

   3. THE ≥2000px SCALING LAYER STAYS IN CHARGE OF THE BIG SCREENS. Every
      value above is guarded to < 2000px, so the page's own
      `@media (min-width: 2000px)` type/padding scale keeps deciding what a
      2560px monitor shows. Nothing here overrules it.

   4. THE ENTRANCE IS THERE ON EVERY LOAD — see the end of this file.

   NO NEW VOCABULARY: faHeroLineClear / faHeroChip / faHeroRise / faHeroDot —
   the hero's own keyframes, at the hero's own beats.
   ================================================================== */

/* ------------------------------------------------------------------ *
 * 1. ONE PAINT OWNER
 * ------------------------------------------------------------------ */

/* the walker stands down inside the hero: an armed class that arrived while
   the hero's own beats were running must never blank, lift or delay a block */
html .hero-section .fa-reveal,
html .hero-section .fa-reveal.is-revealed,
html .hero-section .is-revealed {
  opacity: 1 !important;
  transform: none !important;
  transition: none !important;
}

/* the duplicated class on the wrapper and its child: the BOX moves, the
   <img> inside it only rides — no entrance, no transform, no filter of its
   own, and nothing any later layer writes into that class can give it one */
html .hero-section .hero-img img {
  animation: none !important;
  transform: none !important;
  filter: none !important;
  opacity: 1 !important;
}

/* ------------------------------------------------------------------ *
 * 2. THE HERO'S OWN BEATS, NAMED HERE — ALL OF THEM
 *
 *    One layer decides the whole entrance. The `html body` prefix plus
 *    !important is the same shape src/styles/about-entrance.css uses for
 *    /about: the page's own declarations and its reduced-motion guards
 *    address these very elements, and these beats must win that cascade in
 *    every breakpoint and in every motion preference.
 * ------------------------------------------------------------------ */

/* THE TWO MASKED TITLE LINES — faHeroLineClear, one line after the other.
   100% + 2em is measured against the CLIP BOX (the title line plus the line
   under it), so each line waits fully hidden and slides up from under the
   mask; and with 180ms / 310ms the first line has cleared the mask before
   the second one begins to move — they can never cross on the way up. */
html body .hero-section .hero-title .hero-line > span {
  animation: faHeroLineClear 1.05s cubic-bezier(0.19, 1, 0.22, 1) 310ms 1 normal backwards running !important;
}

/* the title line of the mask, on its own beat */
html body .hero-section .hero-title .hero-line > .hero-heading {
  animation: faHeroLineClear 1.05s cubic-bezier(0.19, 1, 0.22, 1) 180ms 1 normal backwards running !important;
}

/* the pills, the copy, the button, the dot — the hero's beats, in order */
html body .hero-section .hero-badge {
  animation: faHeroChip 0.9s cubic-bezier(0.19, 1, 0.22, 1) 100ms 1 normal backwards running !important;
}

html body .hero-section .location-chip {
  animation: faHeroChip 0.9s cubic-bezier(0.19, 1, 0.22, 1) 220ms 1 normal backwards running !important;
}

html body .hero-section .hero-subtext {
  animation: faHeroRise 0.95s cubic-bezier(0.19, 1, 0.22, 1) 520ms 1 normal backwards running !important;
}

/* THE IMAGE BOX OWNS ITS OWN BEAT, HERE — not in the site's lowest-priority
   sheet. .hero-img sits on the box and on the <img> inside it, so the box is
   the owner (section 1) and its beat is stated next to that guard. */
html body .hero-section .hero-img {
  animation: faHeroRise 0.95s cubic-bezier(0.19, 1, 0.22, 1) 560ms 1 normal backwards running !important;
}

html body .hero-section .magnetic-btn-wrap {
  animation: faHeroRise 1s cubic-bezier(0.19, 1, 0.22, 1) 700ms 1 normal backwards running !important;
}

html body .hero-section .status-dot {
  animation: faHeroDot 2.6s ease-in-out 0s infinite normal none running !important;
}

/* ------------------------------------------------------------------ *
 * 3. THE BLOCK'S OWN RHYTHM, SPACING, ALIGNMENT AND TEXT METRICS
 *    (< 2000px — above that the page's own min-width:2000px scale decides)
 * ------------------------------------------------------------------ */

@media (max-width: 1999.98px) {
  /* the hero's frame */
  html .hero-section {
    padding: 160px 0 0 60px !important;
  }

  /* the text column and the two rows inside it */
  html .hero-section .hero-text {
    display: flex !important;
    flex-direction: column !important;
    justify-content: center !important;
    gap: var(--space-1-xs) !important;
  }

  html .hero-section .hero-footer {
    display: flex !important;
    flex-direction: column !important;
    gap: var(--space-1-2xs) !important;
  }

  html .hero-section .hero-meta-row {
    display: flex !important;
    flex-wrap: wrap !important;
    align-items: center !important;
    gap: 20px !important;
  }

  html .hero-section .hero-title {
    display: flex !important;
    flex-direction: column !important;
  }

  /* the two pills — the row's items, whose position is read from the width
     of their own text: the box and the metrics are fixed here so a webfont
     landing mid-entrance can not re-measure the row and shift the second
     pill sideways */
  html .hero-section .hero-badge {
    display: inline-flex !important;
    align-items: center !important;
    gap: 10px !important;
    padding: 8px 16px !important;
    border-radius: 30px !important;
    width: fit-content !important;
    font-size: 0.85rem !important;
    line-height: 1.6 !important;
    color: var(--text-muted) !important;
    margin-bottom: 30px !important;
  }

  html .hero-section .hero-meta-row .hero-badge {
    margin-bottom: 0 !important;
  }

  html .hero-section .location-chip {
    display: inline-flex !important;
    align-items: center !important;
    gap: 10px !important;
    padding: 8px 16px 8px 11px !important;
    border-radius: 999px !important;
    font-size: 0.85rem !important;
    line-height: 1.6 !important;
    letter-spacing: 0.2px !important;
    color: var(--text-secondary) !important;
  }

  /* the masked lines — the metrics their own travel is measured from */
  html .hero-section .hero-title .hero-heading {
    font-size: 60px !important;
    line-height: 1 !important;
    text-transform: uppercase !important;
  }

  /* THE LINE UNDER THE TITLE IS A LINE OF THE MASK: its height and its font
     size decide how far it has to travel to clear the clip box, so both are
     named here — and its face is taken from the font token, so the metric
     fallback in src/styles/font-metrics.css measures it exactly like Manrope
     itself does: the first paint and the paint after the webfont landed are
     the same line box. */
  html .hero-section .hero-title .hero-line > .subheading-hero {
    font-family: var(--font-manrope) !important;
    font-weight: 800 !important;
    font-size: 2rem !important;
    line-height: 1.2 !important;
  }

  /* the copy — its own type, not the heading classes that are also on it */
  html .hero-section .hero-subtext {
    font-size: 1rem !important;
    line-height: 1.2 !important;
    font-weight: 400 !important;
    color: var(--text-muted) !important;
  }

  /* the button's own box (its bar is decided by src/styles/button-size.css,
     which is authoritative for every label button and is NOT restated here) */
  html .hero-section .magnetic-btn-wrap {
    display: inline-block !important;
    margin-top: 4px !important;
  }

  html .hero-section .status-dot {
    width: 8px !important;
    height: 8px !important;
    border-radius: 50% !important;
    background-color: var(--accent-red) !important;
  }
}

/* ---- tablet & phone, at the house's half-pixel bounds -------------- */

@media (max-width: 768.5px) {
  /* the hero's frame keeps its smaller side room on a tablet and a phone —
     and the page's own CSS states the same sides now, so this is not a
     change the hero can step through */
  html .hero-section {
    padding-left: 30px !important;
    padding-right: 30px !important;
  }

  html .hero-section .hero-text {
    gap: var(--space-1-s) !important;
  }

  html .hero-section .hero-meta-row {
    flex-direction: column !important;
    align-items: flex-start !important;
    gap: 10px !important;
  }

  html .hero-section .hero-title .hero-heading {
    font-size: 40px !important;
  }

  html .hero-section .hero-title .hero-line > .subheading-hero {
    font-size: 20px !important;
    line-height: 1.5 !important;
  }

  html .hero-section .hero-subtext {
    line-height: 1.5 !important;
    width: 100% !important;
  }
}

@media (max-width: 375.5px) {
  html .hero-section .hero-badge,
  html .hero-section .location-chip .hero-badge-mobile {
    font-size: 12px !important;
  }

  html .hero-section .hero-title {
    gap: 1px !important;
  }
}

/* ------------------------------------------------------------------ *
 * 4. THE ENTRANCE IS THERE ON EVERY LOAD  (the /about contract)
 *
 *    src/styles/about-entrance.css states it for /about and this sheet states
 *    it for the home hero: the beats in section 2 are NOT switched off for
 *    `prefers-reduced-motion`. Until this revision they were — in the page's
 *    own CSS, in this sheet and in the mask sheet — while /about kept
 *    animating, which is why the home hero could read as having no entrance
 *    at all on a machine whose OS asks for less motion.
 *
 *    To hand the decision back to the OS preference, append:
 *
 *      @media (prefers-reduced-motion: reduce) {
 *        html body .hero-section .hero-badge,
 *        html body .hero-section .location-chip,
 *        html body .hero-section .hero-subtext,
 *        html body .hero-section .hero-img,
 *        html body .hero-section .magnetic-btn-wrap,
 *        html body .hero-section .status-dot,
 *        html body .hero-section .hero-title .hero-line > span {
 *          animation: none !important;
 *        }
 *      }
 *
 *    (The decorative loops are not part of the entrance: the spinning globe
 *    keeps the page's own reduced-motion guard.)
 * ------------------------------------------------------------------ */


/* src/styles/work-large-screen.css */
/* Work page — the cards on a large desktop screen (Full HD, 2K, 4K)
   ==================================================================
   REQUEST: "Perbaiki card pada halaman work khusus di layar besar.
   Layar desktop besar, dengan resolusi Full HD ke atas (2K dan 4K),
   harus memastikan bahwa card-nya berada di tengah. Pastikan juga lebar
   card-nya memiliki max width."

   WHAT WAS WRONG FROM 1441px UP
     The /work column is .section-padding, and that class caps itself with
     `max-width: 1440px` but declares NO side margins. So as soon as the
     window is wider than the design — 1441px, and more and more on a 1920
     (Full HD), 2560 (2K) or 3840 (4K) screen — the capped block stops
     growing and stays welded to the LEFT edge: both cards sit in the left
     half of the screen and the entire free gutter stacks up on their
     right. Nothing looks broken at or below 1440px, where the block fills
     the viewport — which is exactly why the fault only shows on a big
     screen.
     The page's header behaves the same way for the same reason:
     .page-header is `width: 100%` with 60px of padding and no container,
     so its title stays pinned to the far left while the cards below it
     sit in the middle.

   WHAT THIS SHEET DOES
     · .fa-work-wide marks the page's two blocks — the header section and
       the section that holds the grid. The class is attached by hand, not
       by a script, and no other route carries it: /about, /contact and the
       homepage keep exactly the layout they have today.
     · both blocks become one real, centred column: still capped at the
       design's 1440px, now with `margin-left/right: auto`, so the leftover
       width of a 2K / 4K screen is split equally left and right instead of
       all of it landing on the right.
     · the header is capped to that same column, so "/SELECTED WORKS" and
       the first card start on one line.
     · the CARD carries its own max-width — 645px, the size the design is
       drawn around (1440 − 2×60px padding = 1320, minus the 30px gutter,
       halved). A card can never grow past the size its 16/10 media, its
       title and its copy were measured for, whatever a later change does
       to the column.
     · the rules below are unconditional because at 1440px and under they
       are all no-ops: the block already fills the viewport, so the auto
       margins resolve to 0, and the grid is already narrower than the
       1320/645 caps. A large screen is the only place they can act, so
       there is nothing for a media query to gate.
     · only width / max-width / margins move — no metric INSIDE a card, so
       the entrance beats in src/styles/work-entrance.css, the hover zoom,
       the red hairline and the cursor behaviour are all untouched.

   The card layouts themselves are untouched as well: the stacked card
   (image on top) stays with work-card-tablet.css up to 1700px and with
   work-card-mobile.css at 768px and below, and above 1700px the desktop
   design (text over the image) stays exactly as it is.
   ================================================================== */

/* ------------------------------------------------------------------ *
 * 1. the page's column — its header and the section that holds the grid
 * ------------------------------------------------------------------ */

html body .fa-work-wide.section-padding,
html body .fa-work-wide.page-header {
  width: 100%;
  max-width: 1440px;
  margin-left: auto;
  margin-right: auto;
}

/* ------------------------------------------------------------------ *
 * 2. the grid, held inside that column's own 60px of padding
 *    (1440 − 2×60px = 1320)
 * ------------------------------------------------------------------ */

html body .fa-work-wide .work-list,
html body .fa-work-wide .work-grid {
  width: 100%;
  max-width: 1320px;
  margin-left: auto;
  margin-right: auto;
}

/* ------------------------------------------------------------------ *
 * 3. the card — never wider than the design's half-column
 *    (1320 − 30px gutter, halved = 645)
 *
 *    max-width only, no side margins: a card keeps its own place at the
 *    start of its column, exactly where it sits today, so the two card
 *    columns stay on the same left edge as the header and nothing about
 *    the layout at or below 1440px moves by a single pixel. Above 1700px,
 *    where the description carries no 52ch cap of its own (that cap lives
 *    in work-card-tablet.css), this is also what stops an over-long
 *    description from sizing a card past its own half-column.
 * ------------------------------------------------------------------ */

html body .fa-work-wide .work-card {
  max-width: 645px;
}


/* src/styles/footer.css */
/* Global footer — one layout, three breakpoints, every page except /contact
   ==================================================================
   The footer is the one block of chrome that every page carries its OWN
   copy of (it is not part of the everywhere layout), and those copies had
   drifted apart:

     /            footer.footer.footer-cta-mobile
                    > .footer-cta.footer-cta-mobile   (classes only)
                    > .footer-bottom.footer-bottom-mobile
     /work        footer.footer
                    > .footer-mobile                  (inline styles + its own
                                                       padding and hairline)
                    > div                             (no class at all)
     /about       footer.footer.inline.footer-mobile
                    > .footer-mobile                  (inline styles)
                    > .footer-bottom.footer-bottom-mobile

   Each page also carried its own page-level footer CSS, with the media
   rules of one breakpoint overriding the next. The result, measured on
   the home page:

     · DESKTOP — the footer's own root was still a flex ROW, so the meta
       strip (copyright + links) sat BESIDE the headline instead of under
       it, with its hairline floating in the middle of the footer.
     · TABLET — same row, both columns squeezed to half the width: the
       headline broke over two lines, the copyright wrapped, and the copy
       button dropped onto a line of its own.
     · MOBILE — the red full stop of "Let's Work Together." sat ~6px below
       the headline's baseline, the links were near-white while they were
       grey on desktop, and the same element was 40px on /about.

   This sheet is now the single source of truth for that block: the SAME
   rhythm at 1440, 768 and 375, on /, /work and /about.

   HOW IT IS SCOPED
     Every rule is rooted at `html .footer:not(.footer-contact)`. The
     contact page's footer carries .footer-contact (it is a single centred
     note, not this block), so /contact is left completely alone — no
     property here can reach it. The `html` prefix outranks the page-level
     copies (and is why this sheet, not the page, decides the result).

   NOTHING IS ADDED OR REMOVED
     Not one element, class or attribute changes: the mark-up of the three
     copies is used exactly as it is — only its layout, spacing, type and
     colour are declared here. Two per-page copies carry inline styles
     (the /work and /about CTA row pads itself and paints its own hairline);
     those two declarations are overridden with !important so all three
     pages can share one rhythm, and one more (the mobile stack) because an
     inline `flex-direction: row` cannot be beaten otherwise. Nothing else
     in the file needs it.
   ================================================================== */

/* ------------------------------------------------------------------ *
 * 1. the frame — CTA block on top, one hairline, the meta strip under it
 * ------------------------------------------------------------------ */

html .footer:not(.footer-contact) {
  display: block;
  width: 100%;
  padding: 100px 60px 50px;
  border-top: 1px solid var(--border-color);
  background-color: var(--bg-surface);
}

/* the CTA row: the headline left, "CONTACT NOW" right — at 1440 and at 768.
   The root is a plain block again, so this row and the meta strip always
   stack in the order they are written. */
html .footer:not(.footer-contact) > .footer-cta,
html .footer:not(.footer-contact) > .footer-mobile,
html .footer:not(.footer-contact) > div:first-child {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-1-s);
  width: 100%;
  margin: 0;
  /* the copies that carry inline styles also carry their own padding and
     hairline — this sheet owns both, so every page reads identically */
  padding: 0 !important;
  border-bottom: none !important;
}

/* the headline and its red full stop are ONE line: same face, same size,
   same box, bottoms flush — so the period can never sit off its baseline */
html .footer:not(.footer-contact) > div:first-child > div:first-child {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: flex-start;
  gap: 0;
  min-width: 0;
}

html .footer:not(.footer-contact) .footer-cta-heading,
html .footer:not(.footer-contact) .footer-cta-accent {
  display: block;
  margin: 0;
  padding: 0;
  font-family: var(--font-nippo);
  font-weight: 500;
  font-size: 40px;
  line-height: 1.1;
  letter-spacing: 0;
  text-align: left;
}

html .footer:not(.footer-contact) .footer-cta-accent {
  color: var(--accent-red);
}

/* the button keeps its own size in the row it shares with the headline */
html .footer:not(.footer-contact) .magnetic-btn {
  flex: 0 0 auto;
}

/* ------------------------------------------------------------------ *
 * 2. the meta strip — copyright left, links right (one row at 1440 / 768)
 * ------------------------------------------------------------------ */

html .footer:not(.footer-contact) > .footer-bottom,
html .footer:not(.footer-contact) > div:last-child {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-1-s);
  width: 100%;
  margin: var(--space-1-m) 0 0;
  padding-top: var(--space-1-3xs);
  padding-bottom: var(--space-1-3xs);
  border-top: 1px solid var(--border-color);
  color: var(--text-muted);
  font-size: 0.85rem;
  line-height: 1.55;
}

/* ------------------------------------------------------------------ *
 * 3. the links — WhatsApp and Email read as one family at every size
 * ------------------------------------------------------------------ */

html .footer:not(.footer-contact) .social-links {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-1-xs);
}

html .footer:not(.footer-contact) .social-links .copy-row {
  display: flex;
  flex-direction: row;
  flex-wrap: nowrap;
  align-items: center;
  gap: var(--space-1-2xs);
}

/* both links take the same colour and the same hover — the page copies had
   left WhatsApp grey-and-inert next to an Email that turned red */
html .footer:not(.footer-contact) .social-links .info-val {
  font-family: var(--font-nippo);
  font-size: 1rem;
  font-weight: 500;
  line-height: 1.6;
  color: var(--text-secondary);
  text-decoration: none;
  transition-property: color;
  transition-duration: 0.3s;
  transition-timing-function: ease;
}

html .footer:not(.footer-contact) .social-links .info-val:hover {
  color: var(--accent-red);
}

html .footer:not(.footer-contact) .social-links .info-val:focus-visible {
  outline: 2px solid var(--accent-red);
  outline-offset: 3px;
  border-radius: 2px;
}

/* the copy button keeps the shared round control (src/styles/copy-email.css):
   hairline border, muted icon, and on hover the red fill that flips the icon
   white so it survives the colour behind it */
html .footer:not(.footer-contact) .copy-btn {
  width: 38px;
  height: 38px;
  border: 1px solid var(--border-color);
  border-radius: 999px;
  background-color: transparent;
  color: var(--text-secondary);
}

html .footer:not(.footer-contact) .copy-btn:hover {
  color: var(--text-white);
  border-color: var(--accent-red);
  background-color: var(--accent-red);
  transform: translateY(-1px);
}

/* ------------------------------------------------------------------ *
 * 4. the entrance — the same rise, the same distance, on every page
 *
 *    The footer is revealed on scroll by src/scripts/reveal.js, which
 *    writes its own distance/duration onto each part (30px / 950ms). The
 *    footer is the closing beat of a page, so it gets one calmer value
 *    everywhere — and only here: controls whose transform belongs to the
 *    cursor script (.fa-reveal-fade) are excluded, so the magnetic button
 *    and the copy button keep fading in place as they should.
 * ------------------------------------------------------------------ */

html .footer:not(.footer-contact) .fa-reveal:not(.fa-reveal-fade) {
  --fa-reveal-dur: 900ms !important;
  --fa-reveal-y: 26px !important;
}

/* ------------------------------------------------------------------ *
 * 5. tablet — same row, smaller type, tighter frame
 *
 *    The bound is 769px, one pixel past the band's nominal 768: a frame (and
 *    a real phone or tablet) lands a hair ABOVE its nominal width, so a query
 *    written at exactly 768px never matches there and the footer kept its
 *    desktop rhythm. 769 is the same lower bound the house already uses for
 *    the tablet work cards (src/styles/work-card-tablet.css).
 * ------------------------------------------------------------------ */

@media (max-width: 769px) {
  html .footer:not(.footer-contact) {
    padding: 72px 30px 40px;
  }

  html .footer:not(.footer-contact) .footer-cta-heading,
  html .footer:not(.footer-contact) .footer-cta-accent {
    font-size: 30px;
    line-height: 1.15;
  }

  html .footer:not(.footer-contact) .social-links .info-val {
    font-size: 0.95rem;
  }

  html .footer:not(.footer-contact) .copy-btn {
    width: 34px;
    height: 34px;
  }
}

/* ------------------------------------------------------------------ *
 * 6. mobile — one centred column: headline, then the button, a hairline,
 *    the links, and the copyright last
 *
 *    Bound 376px, for the same reason the tablet block uses 769: the band's
 *    nominal 375 is always rendered a hair wider.
 *
 *    Every selector in here carries the `html body` prefix: this block and
 *    the tablet one both match down here, and the prefix is what keeps the
 *    phone layout winning whichever order the two blocks land in. The
 *    !important on flex-direction is for the /work and /about copies, which
 *    carry `flex-direction: row` inline.
 * ------------------------------------------------------------------ */

@media (max-width: 376px) {
  html body .footer:not(.footer-contact) {
    padding: 56px 20px 32px;
  }

  html body .footer:not(.footer-contact) > div:first-child {
    flex-direction: column !important;
    align-items: center;
    gap: var(--space-1-s);
  }

  html body .footer:not(.footer-contact) .footer-cta-heading,
  html body .footer:not(.footer-contact) .footer-cta-accent {
    font-size: 20px;
    line-height: 1.2;
    text-align: center;
  }

  /* links above, copyright last — one centred stack */
  html body .footer:not(.footer-contact) > .footer-bottom,
  html body .footer:not(.footer-contact) > div:last-child {
    flex-direction: column-reverse;
    align-items: center;
    text-align: center;
    gap: var(--space-1-s);
  }

  html body .footer:not(.footer-contact) .social-links {
    justify-content: center;
    flex-wrap: nowrap;
  }

  html body .footer:not(.footer-contact) .social-links .info-val {
    font-size: 0.9rem;
  }
}

/* ------------------------------------------------------------------ *
 * 7. no motion — nothing here animates on its own, so this only has to
 *    cover the reveal transition of the parts
 * ------------------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  html .footer:not(.footer-contact) .social-links .info-val,
  html .footer:not(.footer-contact) .copy-btn {
    transition-duration: 0.01ms;
  }
}


/* src/styles/cursor.css */
/* Cursor behaviour
   ------------------------------------------------------------------
   The cursor shells (#cursor dot + #cursor-follower ring) and the shared
   button styles live in the everywhere-layout template. This sheet only
   refines the interaction:

   1. the ring never blurs — it stays a clean outline in every state
   2. small red marks around the page act as magnets: the cursor's red dot is
      pulled into them and absorbed. Travel + scale are driven from
      designbyFA/script.js, this sheet only adds the "absorbed" pulse
   3. controls that turn red on hover are filled from the exact point the
      pointer entered, spreading outwards like water filling a container
   ------------------------------------------------------------------ */

/* 1 — ring: no blur, ever ------------------------------------------------ */
html #cursor-follower,
html #cursor-follower.hovered {
  backdrop-filter: none !important;
  -webkit-backdrop-filter: none !important;
  filter: none !important;
}

/* 1a — the ring keeps its RESTING size in every state ------------------- *
   designbyFA/script.js puts .hovered on the ring whenever the pointer enters
   a link, button, input, label or [role="button"], and the hover state used
   to grow that box — so the circle ballooned wherever the pointer went.
   The ring is now locked to the exact resting box it has at rest (36px,
   centred with translate(-50%, -50%)), whatever the pointer is over.
   Nothing about the ring changes on hover any more — not the size, not the
   shape, not the centre and not the paint — so walking onto a link or a
   button leaves the circle exactly as it is at rest. The lock is deliberately
   over-qualified (#cursor-follower#cursor-follower under html body) so it
   outranks every other rule that could reach the ring: a hover/shape rule
   written with :has(), a bare .hovered / .magnetic-hover class, or an
   !important rule in a page sheet. */
html body #cursor-follower#cursor-follower,
html body #cursor-follower#cursor-follower[class],
html body #cursor-follower#cursor-follower.hovered,
html body #cursor-follower#cursor-follower.magnetic-hover,
html body #cursor-follower#cursor-follower:hover {
  width: 36px !important;
  height: 36px !important;
  /* the box can never be resized OR rescaled by any hover state — not even by
     the independent `scale` property, which a transform lock would not catch */
  min-width: 36px !important;
  max-width: 36px !important;
  min-height: 36px !important;
  max-height: 36px !important;
  scale: none !important;
  transform: translate(-50%, -50%) !important;
  /* the ring is always a circle: the box, its ratio, its corners and its
     edges are pinned, so no hover state can square it off or squash it */
  aspect-ratio: 1 / 1 !important;
  box-sizing: border-box !important;
  padding: 0 !important;
  /* no offset can be dialled in from a hover state either — the centre of the
     circle is always exactly the pointer, never nudged a few px off it */
  margin: 0 !important;
  border-width: 1px !important;
  border-style: solid !important;
  border-radius: 50% !important;
  overflow: visible !important;
  /* and it can never be re-cut, offset or re-mixed into another silhouette */
  outline: none !important;
  clip-path: none !important;
  rotate: none !important;
  translate: none !important;
  mix-blend-mode: normal !important;
  /* no animation and no transition is left at all, so the ring cannot morph
     or glide into a different look while a control is hovered */
  animation: none !important;
  transition: none !important;
  /* the resting paint is pinned as well — the circle reads identically over
     a button, a link or an empty area */
  border-color: rgba(255, 255, 255, 0.3) !important;
  background-color: rgba(255, 255, 255, 0.02) !important;
  box-shadow: rgba(0, 0, 0, 0.4) 0 0 0 1px, rgba(0, 0, 0, 0.35) 0 4px 16px !important;
}

/* the dot is moved by the script — keep it on the compositor. Its size is
   fixed here as well (40px clip box, 7px circle inside it), so no hover style
   can ever enlarge the red dot either. */
html #cursor {
  will-change: transform, opacity;
}

/* 1b — the dot's clip box ----------------------------------------------- *
   Shape mimicry is DISABLED: the dot never copies the silhouette of the red
   mark it is near — it stays a plain 7px circle in every state. */
html #cursor {
  width: 40px;
  height: 40px;
  margin-top: -20px;
  margin-left: -20px;
  border-radius: 0;
  clip-path: circle(3.5px at 50% 50%);
}

/* 1c — the magnet is OFF ------------------------------------------------ *
   The dot used to leave the ring and be drawn into any red mark on the page
   — travel, absorb pulse, the nav "rail" it glided along — and it stretched
   into a short line on the way. That is all switched off, in this sheet AND
   at the source (designbyFA/script.js, MAGNET_ON / STRETCH_ON / SHAPE_ON).

   What is left of the custom cursor is that it FOLLOWS the pointer. These
   two locks say so at the CSS level as well, so no inline style the script
   writes — and no stale state from a soft page swap — can reshape or offset
   the dot again:
     · the clip box always clips a circle, never a polygon silhouette
     · the box is never offset or stretched by a transform: the dot's
       position comes from left/top alone, so transform is free to be pinned
   The ring is already pinned to its 36px resting box below (1a). */
html #cursor {
  clip-path: circle(3.5px at 50% 50%) !important;
  -webkit-clip-path: circle(3.5px at 50% 50%) !important;
  transform: none !important;
}

/* 2 — red mark "absorbed" pulse ------------------------------------------ */
.cursor-absorb {
  transform-box: fill-box;
  transform-origin: center;
  animation: cursorAbsorb 0.46s cubic-bezier(0.25, 1, 0.5, 1);
}

@keyframes cursorAbsorb {
  0% {
    transform: scale(1);
  }
  35% {
    transform: scale(1.3);
  }
  100% {
    transform: scale(1);
  }
}

/* 3 — "water" red fill from the pointer's entry point -------------------- */

/* every fillable control carries its own origin, defaulting to its centre */
html .magnetic-btn,
html .mobile-cta,
.js-red-fill {
  --fill-x: 50%;
  --fill-y: 50%;
}

/* .magnetic-btn already ships a red ::before — it used to rise from the
   bottom edge; now it spreads as a disc from the entry point */
html .magnetic-btn::before {
  height: 100% !important;
  clip-path: circle(0% at var(--fill-x, 50%) var(--fill-y, 50%));
  transition-property: clip-path !important;
  transition-duration: 0.45s !important;
  transition-timing-function: cubic-bezier(0.4, 0.1, 0.3, 1) !important;
  transition-delay: 0s !important;
  will-change: clip-path;
}

html .magnetic-btn:hover::before {
  height: 100% !important;
  clip-path: circle(160% at var(--fill-x, 50%) var(--fill-y, 50%));
  transition-duration: 0.72s !important;
  transition-timing-function: cubic-bezier(0.33, 0.08, 0.22, 1) !important;
}

/* the header CTA is a .magnetic-btn whose own :hover paints a solid red —
   that would cover the spreading fill, so it keeps its resting tint */
html .header-cta:hover {
  background-color: color-mix(in srgb, var(--accent-red) 6%, transparent) !important;
}

/* its arrow turns white as the red actually reaches it */
html .header-cta::after {
  transition-property: transform, color;
  transition-duration: 0.3s, 0.35s;
  transition-delay: 0s, 0.16s;
}

/* the mobile overlay CTA has no pseudo yet — give it the same water fill */
html .mobile-cta {
  position: relative;
  overflow: hidden;
  isolation: isolate;
}

html .mobile-cta::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background-color: var(--accent-red);
  clip-path: circle(0% at var(--fill-x, 50%) var(--fill-y, 50%));
  transition-property: clip-path;
  transition-duration: 0.45s;
  transition-timing-function: cubic-bezier(0.4, 0.1, 0.3, 1);
  will-change: clip-path;
  pointer-events: none;
}

html .mobile-cta:hover::before {
  clip-path: circle(160% at var(--fill-x, 50%) var(--fill-y, 50%));
  transition-duration: 0.72s;
  transition-timing-function: cubic-bezier(0.33, 0.08, 0.22, 1);
}

html .mobile-cta:hover {
  background-color: color-mix(in srgb, var(--accent-red) 6%, transparent) !important;
}

/* other controls that paint themselves red on hover get the same treatment.
   The script detects them from the stylesheets and adds .js-red-fill together
   with their resting colour in --js-rest-bg. (rgba red at alpha 0 is the
   transparent partner of the accent token — it keeps the edge from greying.) */
@property --fill-r {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}

.js-red-fill {
  --fill-r: 0%;
  background-image: radial-gradient(
    circle at var(--fill-x, 50%) var(--fill-y, 50%),
    var(--accent-red) var(--fill-r),
    rgba(255, 51, 51, 0) calc(var(--fill-r) + 1.5px)
  );
  transition-property: --fill-r;
  transition-duration: 0.45s;
  transition-timing-function: cubic-bezier(0.4, 0.1, 0.3, 1);
}

.js-red-fill:hover {
  --fill-r: 160%;
  background-color: var(--js-rest-bg, transparent) !important;
  transition-duration: 0.72s;
  transition-timing-function: cubic-bezier(0.33, 0.08, 0.22, 1);
}

@media (prefers-reduced-motion: reduce) {
  html .magnetic-btn::before,
  html .mobile-cta::before,
  .js-red-fill {
    transition-duration: 0.01ms !important;
  }
  .cursor-absorb {
    animation-duration: 0.01ms;
    animation-iteration-count: 1;
  }
}


/* src/styles/font-metrics.css */
/* Font metrics — the fallback that measures like the real face
   ==================================================================
   WHY THIS SHEET EXISTS
   ------------------------------------------------------------------
   The rows that are measured in TYPE — the tag / pill row
   (.work-tags, .svc-tags, .skills-tags, .exp-role-row) and the meta line of a
   work card (.work-card-number, .work-card-title, .work-card-desc,
   .work-card-link) — lay themselves out from the width of their own text.
   Until the webfonts have landed, the browser paints those rows in the
   system fallback (plain Arial / Helvetica), and the moment Inter, Manrope
   or Nippo arrive every text run re-measures: the row rewraps and, from the
   second item onwards, the items shift — right in the middle of the
   entrance that is still running over them (src/scripts/reveal.js +
   src/styles/reveal.css).

   That is a FONT problem, not an animation problem: slowing the entrance
   down would only hide it, and the row would still move for anyone who is
   already reading it.

   THE REMEDY — A FALLBACK FACE WITH THE SAME METRICS
   ------------------------------------------------------------------
   Each family gets a companion @font-face that is nothing but a LOCAL system
   font, resized with `size-adjust` until its advance widths are the ones the
   real face would have produced. The companion is then appended to the token
   stack, directly after the real face:

       "Inter", "Inter Metric Fallback", sans-serif

   so all three states of the page measure the same:

     · webfont not arrived yet  → the metric fallback (the same widths)
     · webfont arrived          → the real face
     · webfont never arrives    → the metric fallback, still the same widths

   A row that is mid-entrance can therefore no longer be knocked out of line
   by a swap. The adjustment only ever applies while the real face is
   missing, so nothing changes shape once the font has loaded; no page, no
   block and no class is named here — the whole site is covered through the
   three tokens, which is why this rule is written on :root.

   THE CHECK THIS ANSWERS — repeat it on a slow connection
   ------------------------------------------------------------------
   Throttle the network (or clear the font cache) and load /work and /:
   the tag row and the work-card meta used to re-measure a beat after the
   page painted, which is the shift that was reported. With the companions
   in the stack the rows keep their first-paint widths and the reveal over
   them is untouched.

   If a real face is ever replaced by a wider or narrower one, its
   size-adjust is the single value to re-measure here.
   ================================================================== */

/* Inter — the body face. Inter's advance widths run ~7% wider than Arial's
   at the same size, so the Arial-based companion is scaled up to match. */
@font-face {
  font-family: "Inter Metric Fallback";
  src: local("Arial"), local("Helvetica Neue"), local("Helvetica"), local("Liberation Sans"), local("Roboto");
  size-adjust: 107%;
  line-gap-override: 0%;
}

/* Manrope — the label face (chips, meta, buttons). Slightly wider than
   Arial; the companions of the two faces are close enough that one stack
   covers both, but each is named so a future re-measure stays local. */
@font-face {
  font-family: "Manrope Metric Fallback";
  src: local("Arial"), local("Helvetica Neue"), local("Helvetica"), local("Liberation Sans"), local("Roboto");
  size-adjust: 106%;
  line-gap-override: 0%;
}

/* Nippo — the display face (years, counters, the footer headline). It is a
   display cut rather than a text face, so its companion is left at the
   natural system ratio; the value that matters for these rows is that the
   stack resolves to the same measurements before and after a swap. */
@font-face {
  font-family: "Nippo Metric Fallback";
  src: local("Arial"), local("Helvetica Neue"), local("Helvetica"), local("Liberation Sans");
  size-adjust: 100%;
  line-gap-override: 0%;
}

/* --- the tokens themselves, so every rule that reads them is covered --- */

/* WHY THIS IS `html:root` AND NOT `:root`.
   The three tokens are also declared by the token system itself — every
   document (the everywhere layout AND each page) opens with
   `:root { --font-primary: "Inter", sans-serif; … }` — so the stack written
   here is only in charge while this sheet is the LAST of those declarations to
   land. It is a sheet loaded as a file, i.e. it lands AFTER the page's own
   CSS: for the length of that window the site paints in the plain stack, with
   the REAL face's widths, and the moment this sheet arrives every text run
   re-measures — exactly the shift the row/meta promise below is written to
   prevent.

   Naming the element as well as the pseudo-class settles it for good:
   `html:root` is one element deeper in specificity than `:root`, on the very
   same element (the document element), so these three declarations win
   whichever layer lands last — the layout's token block, the page's token
   block, or a later token group. The companion faces are therefore in the
   stack from the FIRST paint, which is the whole point of this sheet. */

html:root {
  --font-primary: "Inter", "Inter Metric Fallback", sans-serif;
  --font-manrope: "Manrope", "Manrope Metric Fallback", sans-serif;
  --font-nippo: "Nippo", "Nippo Metric Fallback", sans-serif;
}


/* src/styles/button-size.css */
/* Button size — ONE size per breakpoint, taken from the header CTA
   ==================================================================
   REQUEST: "buat semua ukuran tombol konsisten di semua halaman, jadikan
   ukuran tombol di CTA header desktop [let's talk] sebagai acuannya, lalu
   sesuaikan dengan setiap breakpoint — misinya adalah membuat tombol
   ukurannya selaras."

   THE MEASURING STICK
   ------------------------------------------------------------------
   The everywhere layout's desktop header CTA (.magnetic-btn.header-cta) is
   the reference, and this sheet never touches it: 11px / 22px of padding
   around a 1.6 line box, Manrope 700 at .75rem, tracking .14em, uppercase,
   .55rem between the label and its arrow, a 1px accent-red hairline around
   it — a bar that reads ~43px tall at every desktop width. That is the bar
   every other label button of the site is brought onto, per breakpoint:

     desktop  (base)      type .75rem  tracking .14em  11px 22px  gap .55rem
     tablet   (≤768px)    type .75rem  tracking .12em  11px 18px  gap .50rem
     mobile   (≤375px)    type .72rem  tracking .10em  11px 16px  gap .45rem

   …and ONE NUMBER THAT DOES NOT STEP: THE BAR ITSELF. Every button is pinned
   to --fa-btn-h — 43.2px, the header CTA's own measured height — at all three
   breakpoints. Only the room to the sides and the tracking adapt, so a button
   is the same box on a phone, a tablet and a desktop, and the same box as the
   header CTA it was measured from.

   The vertical padding (11px) and the line box (1.6) never move, so a
   button is the same ~43px tall on every screen — only the room to the
   sides, the tracking and (on the phone) one step of type step down. That
   is what keeps a long label such as "DISCUSS YOUR PROJECT" on one line
   inside 375px without ever making a button jump in height.

   WHAT IT COVERS — every label button of the site
     .magnetic-btn   the home hero's "View Portfolio", "Get to Know Me",
                     "Discuss Your Project", "Start Your Project" and
                     "CONTACT NOW"; the same footer CTA on /work and /about;
                     the contact form's "Send Message"
                     (.magnetic-btn.submit-btn on /contact).
     .mobile-cta     the mobile menu's counterpart of the header CTA — the
                     same button, one breakpoint down.

   WHAT IT NEVER TOUCHES
     · the header CTA itself — it is the reference, pixel for pixel, so it is
       left out of the rule entirely (:not(.header-cta));
     · the icon-only controls (the copy button, the hamburger) — they are not
       label buttons and are sized by their own hit area;
     · layout: no width, no margin, no position, no display, no justify —
       and no transition, hover fill or magnetic transform is redeclared
       here. Only padding, type, tracking, the gap to the arrow and the
       arrow's own size are decided.
   ================================================================== */

:root {
  --fa-btn-font: 0.75rem;
  --fa-btn-weight: 700;
  --fa-btn-track: 0.14em;
  --fa-btn-pad-y: 11px;
  --fa-btn-pad-x: 22px;
  --fa-btn-gap: 0.55rem;
  --fa-btn-line: 1.6;
  /* THE BAR — 11px of padding above and below, a 1px hairline on each side
     and the 19.2px line box of .75rem at 1.6 = the header CTA's own measured
     height. Written as a fixed value on purpose: derived from the breakpoint's
     type it would shrink the moment the phone steps the type down, and the
     bar would stop matching the reference on exactly the screens where it
     matters most. */
  --fa-btn-h: 43.2px;
}

/* tablet — the same bar, less room to the sides.
   The half-pixel epsilon is deliberate: a breakpoint frame measures a hair
   over its nominal width (the tablet frame lays out at 768.18px), so a bare
   `max-width: 768px` would never match the very frame it describes and the
   canvas would keep showing the desktop bar. On a real 768px screen the
   epsilon changes nothing. */
@media (max-width: 768.5px) {
  :root {
    --fa-btn-track: 0.12em;
    --fa-btn-pad-x: 18px;
    --fa-btn-gap: 0.5rem;
  }
}

/* phone — one step of type smaller, and the tightest sides (same half-pixel
   epsilon as the tablet step, so this is the step the phone frame shows) */
@media (max-width: 375.5px) {
  :root {
    --fa-btn-font: 0.72rem;
    --fa-btn-track: 0.1em;
    --fa-btn-pad-x: 16px;
    --fa-btn-gap: 0.45rem;
  }
}

/* --- the one size every label button shares, at every breakpoint ------- */

html body .magnetic-btn:not(.header-cta),
html body .mobile-cta {
  box-sizing: border-box;
  /* the pin — the bar is the reference's height and only that height, whatever
     a label, an icon or a breakpoint's smaller type would otherwise make it.
     It carries !important for the same reason the rest of this rule does: the
     page-level button rules it has to outrank. */
  min-height: var(--fa-btn-h) !important;
  padding: var(--fa-btn-pad-y) var(--fa-btn-pad-x) !important;
  font-family: var(--font-manrope) !important;
  font-size: var(--fa-btn-font) !important;
  font-weight: var(--fa-btn-weight) !important;
  line-height: var(--fa-btn-line) !important;
  letter-spacing: var(--fa-btn-track) !important;
  text-transform: uppercase !important;
  text-align: center;
  gap: var(--fa-btn-gap) !important;
  row-gap: var(--fa-btn-gap) !important;
  column-gap: var(--fa-btn-gap) !important;
  white-space: nowrap;
}

/* the arrow (or icon) that sits beside the label belongs to the same bar: it
   is measured in em rather than in fixed pixels, so it follows the label at
   every breakpoint — the way the hero's own 2000px rule already reads it */
html body .magnetic-btn:not(.header-cta) > svg,
html body .mobile-cta > svg {
  width: 1.125em;
  height: 1.125em;
  flex: 0 0 auto;
}

/* a label that carried a stray line break from the editor (the footer's
   "CONTACT NOW<br>") is ONE line of type: that break must not add a second,
   empty line box and stretch these buttons taller than the reference */
html body .magnetic-btn:not(.header-cta) br {
  display: none;
}


/* src/styles/plan-price-mobile.css */
/* Plan prices — one line on phones
   ==================================================================
   Sebelumnya di layar sempit label harga terbelah jadi dua baris
   ("IDR" lalu "2,500,000"), karena .plan .price dinaikkan ke 40px
   sementara kartu plan-nya sempit.

   Di sini ketiga harga (IDR 2,500,000 / 3,500,000 / 5,000,000)
   dipaksa satu baris dan type-nya mengikuti lebar layar dengan batas
   atas 30px, jadi selalu muat di dalam kartu tanpa pecah baris.

   Ruang lingkup: hanya ponsel (≤480px). Tablet dan desktop tidak
   disentuh — di sana harganya tetap 40px seperti semula.
   ================================================================== */

@media (max-width: 480px) {
  html .plans .plan .price {
    /* satu baris, tanpa pernah terpotong di layar terkecil (320px) */
    white-space: nowrap;
    /* ~30px di layar 385px ke atas, menyusut proporsional di bawahnya */
    font-size: clamp(22px, 7.8vw, 30px);
  }
}
