/* ==========================================================================
   Merakigo — site stylesheet
   Written as plain CSS on purpose: no build step, no external font/CDN
   request, so the strict Content-Security-Policy ('self' only) holds.
   ========================================================================== */

/* --------------------------------------------------------------------------
   0. Fonts
   Self-hosted, not loaded from a CDN: the Content-Security-Policy is
   'self'-only, and a Google Fonts link would simply be blocked. The files sit
   in public/assets/fonts and are served from this origin.

   Each family is one variable woff2 covering its whole weight range, which is
   what Google now ships — asking for 600 and 700 separately returned the same
   bytes twice, so only one file per family is stored. `font-weight: 100 900`
   is what tells the browser that one file answers for every weight.

   Only the `latin` subset is included. The site is Indonesian; Cyrillic,
   Greek and Vietnamese subsets would have tripled the download for nothing.

   `font-display: swap` renders the text immediately in the fallback and
   repaints when the font lands, so a slow font never leaves the hero blank.
   -------------------------------------------------------------------------- */
@font-face {
    font-family: "Montserrat";
    font-style: normal;
    font-weight: 100 900;
    font-display: swap;
    src: url("../fonts/montserrat.woff2") format("woff2");
}

@font-face {
    font-family: "Open Sans";
    font-style: normal;
    font-weight: 100 900;
    font-display: swap;
    src: url("../fonts/opensans.woff2") format("woff2");
}

/* Plus Jakarta Sans — the typeface the printed certificate is set in, and now
   the one every button carries, so a control on screen matches the document it
   leads to.

   Three static weights rather than a variable file: buttons use 600, and a
   couple sit at 500 and 700. Shipping only what is used keeps this to about
   125 KB instead of the ~250 KB the full variable font costs.

   Self-hosted, like the other two. Nothing here reaches Google Fonts, so no
   visitor's address is handed to a third party for the sake of a typeface. */
@font-face {
    font-family: "Plus Jakarta Sans";
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: url("../fonts/plusjakartasans-medium.woff2") format("woff2");
}

@font-face {
    font-family: "Plus Jakarta Sans";
    font-style: normal;
    font-weight: 600;
    font-display: swap;
    src: url("../fonts/plusjakartasans-semibold.woff2") format("woff2");
}

@font-face {
    font-family: "Plus Jakarta Sans";
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url("../fonts/plusjakartasans-bold.woff2") format("woff2");
}

/* --------------------------------------------------------------------------
   1. Tokens
   -------------------------------------------------------------------------- */
:root {
    --purple-50:  #F5F3FF;
    --purple-100: #EDE9FE;
    --purple-200: #DDD6FE;
    --purple-400: #A78BFA;
    --purple-500: #8B5CF6;
    --purple-600: #7C3AED;
    --purple-700: #6D28D9;

    --blue-500:   #3B82F6;
    --sky-500:    #4BA8CE;   /* the "Apa itu Agen Digital MAJU?" band */

    /* Row accents in the earnings breakdown, sampled from the supplied
       reference rather than approximated from the purple scale above. */
    --accent-violet:      #6A37E4;
    --accent-blue:        #2376FC;
    --accent-green:       #3FC757;
    --accent-violet-deep: #6032D9;
    --accent-tint:        #F1EFFD;
    --pink-500:   #EC4899;
    --orange-500: #F97316;
    --orange-100: #FFEDD5;
    --blue-100:   #DBEAFE;

    --ink:      #14142B;
    --ink-soft: #3D3D5C;
    --body:     #5F5F7A;
    --muted:    #8B8BA7;

    --surface:      #FFFFFF;
    --surface-soft: #FBFAFF;

    /* A second quiet ground, cooled towards the wordmark's blue rather than
       its purple. Distinct enough from --surface-soft to separate two
       sections that would otherwise run together, quiet enough that it never
       competes with the cards sitting on it. */
    --surface-tint: #F1F6FB;
    --surface-tint-line: #E2ECF5;
    --line:         #ECEAF6;

    /* A firmer hairline for boxes that are nothing but an outline.
       `--line` is tuned to divide things that already sit on a tinted panel;
       on plain white, with no fill and no shadow to help it, it fades to
       almost nothing and the box stops reading as a box. */
    --line-firm:    #DCDAE6;

    --footer-bg:   #141428;
    --footer-line: #2A2A45;
    --footer-text: #A8A8C4;

    /* Sampled from the wordmark itself, so anything wearing this gradient
       matches the logo beside it rather than approximating it. */
    --logo-purple: #663696;
    --logo-blue:   #42A7CE;
    --grad-logo:   linear-gradient(100deg, var(--logo-purple) 0%, var(--logo-blue) 100%);

    --grad-brand: linear-gradient(135deg, #7C3AED 0%, #8B5CF6 45%, #6366F1 100%);
    --grad-cta:   linear-gradient(100deg, #7C3AED 0%, #8B5CF6 55%, #6D5DF6 100%);

    --shadow-sm: 0 1px 2px rgba(20, 20, 43, .05);
    --shadow-md: 0 4px 16px rgba(20, 20, 43, .06);
    --shadow-lg: 0 14px 40px rgba(20, 20, 43, .10);
    --shadow-cta: 0 12px 30px rgba(124, 58, 237, .28);

    /* Cut-out artwork — the EDC, the flow steps, the stat illustrations, the
       money — is transparent PNG with no box to catch a `box-shadow`, so it is
       grounded with a `filter: drop-shadow()` that follows the subject's own
       outline instead of a rectangle around it.

       Two sizes, because the images are two sizes. A single shadow tuned for a
       480px-tall device render turns into a smudge under a 90px icon.

       Two stacked drop-shadows each, not one: a wide soft pool for the lift,
       and a tight dark one right under the edge for the contact. A single
       26px-blur shadow at 20% was mathematically present and practically
       invisible — these illustrations carry their own painted-in shadows, so a
       CSS one has to be firmer than usual before it reads as separate.

       These are `drop-shadow()` functions, not raw shadow values: they go in
       `filter`, never in `box-shadow`. */
    --art-shadow:    drop-shadow(0 18px 24px rgba(20, 20, 43, .26)) drop-shadow(0 3px 6px rgba(20, 20, 43, .14));
    --art-shadow-sm: drop-shadow(0 10px 14px rgba(20, 20, 43, .22)) drop-shadow(0 2px 4px rgba(20, 20, 43, .12));

    --radius-sm: 10px;
    --radius:    16px;
    --radius-lg: 22px;
    --radius-xl: 30px;

    /* Content width. This one value drives the proportions of every section:
       the reference design sits at 1180px, this is a touch roomier without
       stretching a five-card row into a thin ribbon. */
    --container: 1400px;
    --gutter: clamp(20px, 3vw, 40px);

    /* ---------------------------------------------------------------------
       TYPE SCALE
       Every font-size in this stylesheet comes from this list. Nothing picks
       its own value — that is what made section typography drift apart.

       The root font-size already grows 16px → 18px with the viewport, so the
       text steps are plain rem and scale with it automatically. The three
       display sizes add a small viewport term of their own so headings stay
       manageable on a phone and do not overshoot on a wide monitor.
       --------------------------------------------------------------------- */
    --fs-2xs: 0.78rem;   /* badges, hints, inline errors */
    --fs-xs:  0.85rem;   /* meta lines, breadcrumbs, footer legal */
    --fs-sm:  0.92rem;   /* card copy, form fields, nav, footer */
    --fs-md:  1rem;      /* body copy, leads */
    --fs-lg:  1.08rem;   /* card titles, h3 */
    --fs-xl:  1.25rem;   /* prose h2, CTA title */
    --fs-2xl: clamp(1.35rem, 0.7vw + 1.15rem, 1.65rem);   /* section titles */
    --fs-3xl: clamp(1.65rem, 1.1vw + 1.35rem, 2.1rem);    /* page titles, stat figures */
    --fs-4xl: clamp(1.95rem, 1.8vw + 1.5rem, 2.8rem);     /* h1, hero */

    /* Body and sub-headlines: Open Sans Regular. */
    --font: "Open Sans", "Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;

    /* Display face for headings.
       "Segoe UI Variable Display" is Windows 11's optical size cut for large
       text — tighter spacing and crisper joins than the text cut this site
       was using for everything. macOS resolves `system-ui` to SF Pro Display
       at these sizes, Android to Roboto. All three are already on the device,
       so the strict CSP stays intact and no font file is downloaded.

       To swap in a brand face instead: drop a .woff2 into public/assets/fonts,
       declare an @font-face, and put its family first in this list. */
    /* Headings and buttons: Montserrat. Family names only — never a
       weight-specific one like "Segoe UI Black". Those pin the weight and
       then ignore `font-weight`, so the headline could not be made lighter or
       heavier from CSS afterwards. */
    --font-display: "Montserrat", "Segoe UI Variable Display", "Segoe UI",
        system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;

    /* Buttons and other controls. Falls back to the same system stack, so a
       blocked or slow font never leaves a control set in something jarring. */
    --font-ui: "Plus Jakarta Sans", "Segoe UI Variable Display", "Segoe UI",
        system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;

    --ease: cubic-bezier(.4, 0, .2, 1);

    /* A second curve, for things arriving rather than responding: it covers
       most of the distance early and settles slowly. Entrances eased this way
       read as deliberate, where the symmetric curve above reads as mechanical.
       Kept apart from --ease on purpose — hover and press should stay quick
       and even; only arrivals get the long tail. */
    --ease-entrance: cubic-bezier(.16, 1, .3, 1);

    /* Elevation in two layers: a tight contact shadow that holds the shape to
       the page, and a wide soft one for the lift. One blurred shadow alone
       reads as a smudge; two read as an object above a surface. */
    --lift-1: 0 2px 6px rgba(20, 20, 43, .06), 0 12px 28px rgba(20, 20, 43, .10);
    --lift-2: 0 3px 10px rgba(20, 20, 43, .07), 0 22px 48px rgba(20, 20, 43, .13);
}

/* --------------------------------------------------------------------------
   2. Reset / base
   -------------------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }

html {
    -webkit-text-size-adjust: 100%;
    scroll-behavior: smooth;

    /* Body text grows gently with the viewport, 16px to 18px. Because every
       size below is expressed in rem, the whole scale moves together — which
       is what keeps headings and body copy in proportion on a full-width
       layout instead of headings ballooning on their own. */
    font-size: clamp(16px, 0.25vw + 15px, 18px);
}

body {
    margin: 0;
    font-family: var(--font);
    line-height: 1.7;
    color: var(--body);
    background: var(--surface);
    -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4 {
    margin: 0 0 .5em;
    font-family: var(--font-display);
    color: var(--ink);
    line-height: 1.22;
    font-weight: 700;
    letter-spacing: -.015em;
}

h1 { font-size: var(--fs-4xl); }
h2 { font-size: var(--fs-2xl); }
h3 { font-size: var(--fs-lg); }

p { margin: 0 0 1rem; }
p:last-child { margin-bottom: 0; }

a { color: var(--purple-600); text-decoration: none; transition: color .2s var(--ease); }
a:hover { color: var(--purple-700); }

/* `height: auto` is not decoration — without it images are distorted.
 *
 * An <img>'s `width`/`height` attributes are presentational hints: `height="300"`
 * behaves as `height: 300px`. So the moment CSS narrows the image — `max-width:
 * 100%` on a phone, or a smaller `max-width` at a breakpoint — the width shrinks
 * and the height stays pinned at whatever the attribute said. The picture is
 * stretched, and by more the narrower the screen gets.
 *
 * That is exactly what was happening to the illustration in "Kenapa Memilih
 * Agen Digital MAJU": 1467x1072 artwork, `height="300"`, and a 300px-wide box
 * under 900px — so it was drawn 300x300 and stood about 37% too tall.
 *
 * `auto` hands the height back to the image's own proportions. Rules that
 * genuinely want a fixed height (.brand__logo, .hero__photo img) are all more
 * specific and still win.
 */
img, svg { max-width: 100%; height: auto; display: block; }

/* Both list types are reset. Missing `ol` here is what made the breadcrumb
   render as "1. Beranda." — components re-enable markers where they want
   them (see .prose ol). */
ul, ol { margin: 0; padding: 0; list-style: none; }

button { font: inherit; cursor: pointer; }

:focus-visible {
    outline: 3px solid var(--purple-400);
    outline-offset: 3px;
    border-radius: 4px;
}

.skip-link {
    position: absolute;
    left: -9999px;
    top: 0;
    z-index: 999;
    padding: 12px 20px;
    background: var(--purple-600);
    color: #fff;
    border-radius: 0 0 var(--radius-sm) 0;
}
.skip-link:focus { left: 0; color: #fff; }

.container {
    width: 100%;
    max-width: var(--container);
    margin-inline: auto;
    padding-inline: var(--gutter);
}

.visually-hidden {
    position: absolute !important;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0 0 0 0);
    white-space: nowrap; border: 0;
}

.icon { width: 24px; height: 24px; flex-shrink: 0; }
.icon--sm { width: 18px; height: 18px; }
.icon--lg { width: 28px; height: 28px; }
.icon--heart { color: var(--purple-400); fill: currentColor; }

/* --------------------------------------------------------------------------
   3. Buttons
   -------------------------------------------------------------------------- */
/* Buttons take the display face at SemiBold — one step lighter than the
   headline, so they read as the same voice without shouting as loudly. */
.btn {
    --btn-bg: transparent;
    --btn-fg: var(--ink);
    --btn-bd: transparent;

    /* Plus Jakarta Sans on every button, everywhere. It is the typeface the
       certificate is set in, so a control on screen and the document it leads
       to are the same voice. The rest of the page keeps Montserrat. */
    font-family: var(--font-ui);

    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    padding: 12px 24px;
    border: 1.5px solid var(--btn-bd);
    border-radius: var(--radius-sm);
    background: var(--btn-bg);
    color: var(--btn-fg);
    font-size: var(--fs-sm);
    font-weight: 600;
    line-height: 1.2;
    text-align: center;
    transition: transform .2s var(--ease), box-shadow .2s var(--ease), background .2s var(--ease), color .2s var(--ease);
}
.btn:hover { transform: translateY(-2px); color: var(--btn-fg); }
.btn:active { transform: translateY(0); }

/* One purple for every button on the site: --logo-purple, #663696, the colour
   taken from the wordmark.

   The primary button used to be --grad-brand, a violet gradient that did not
   match the logo beside it in the header; the header's own sign-in button had
   already been given a flat #663696 to compensate, so two purples were on
   screen at once. Setting it here means that override is no longer needed and
   the outline and white variants pick up the same hue rather than the old
   --purple-700.

   Shadows are tinted from the same colour, so a button does not cast a violet
   glow under a purple face. */
.btn--primary {
    --btn-bg: var(--logo-purple);
    --btn-fg: #fff;
    box-shadow: 0 6px 18px rgba(102, 54, 150, .25);

    /* A sheen used to sweep across this on hover. It was removed: a light
       travelling over a button is a trick that draws attention to the effect
       rather than to the action, and it repeats on every pass of the cursor.
       What is left is depth — the button lifts, its shadow spreads and softens
       exactly as a real object's would, and pressing puts it back down. */
    position: relative;
}
.btn--primary:hover {
    --btn-bg: #552B7F;
    box-shadow: 0 4px 10px rgba(102, 54, 150, .22), 0 14px 30px rgba(102, 54, 150, .30);
}

/* The press. Every button already lifts on hover; returning it to the page —
   and shrinking the shadow with it — is what makes the click feel connected
   rather than merely registered. */
.btn:active { transform: translateY(1px) scale(.995); }
.btn--primary:active { box-shadow: 0 2px 6px rgba(102, 54, 150, .24); }

.btn--outline {
    --btn-bg: #fff;
    --btn-fg: var(--ink);
    --btn-bd: var(--line);
    box-shadow: var(--shadow-sm);
}
.btn--outline:hover { --btn-bd: var(--logo-purple); --btn-fg: var(--logo-purple); }

.btn--white {
    --btn-bg: #fff;
    --btn-fg: var(--logo-purple);
    box-shadow: 0 8px 22px rgba(20, 20, 43, .16);
}

.btn--ghost-light {
    --btn-bg: rgba(255, 255, 255, .12);
    --btn-fg: #fff;
    --btn-bd: rgba(255, 255, 255, .6);
}
.btn--ghost-light:hover { --btn-bg: rgba(255, 255, 255, .22); }

.btn--lg { padding: 14px 30px; font-size: var(--fs-md); }
.btn--block { width: 100%; }

/* --------------------------------------------------------------------------
   4. Header / navigation
   -------------------------------------------------------------------------- */
.site-header {
    position: sticky;
    top: 0;
    z-index: 100;
    background: rgba(255, 255, 255, .92);
    backdrop-filter: blur(12px);
    border-bottom: 1px solid transparent;
    transition: border-color .25s var(--ease), box-shadow .25s var(--ease);
}
/* Scrolled state: the glass tightens and a hairline appears, rather than a
   shadow being switched on. A drop shadow under a translucent bar reads as two
   separate objects; raising the opacity and the blur keeps it one surface that
   the page passes beneath. */
.site-header.is-stuck {
    background: rgba(255, 255, 255, .96);
    backdrop-filter: blur(18px) saturate(140%);
    border-bottom-color: var(--line);
    box-shadow: 0 1px 0 rgba(20, 20, 43, .04), 0 8px 24px rgba(20, 20, 43, .06);
}

.site-header__inner {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 20px;

    /* Tall enough that the enlarged logo is not flush against the edges. */
    min-height: 86px;
}

.brand { display: inline-flex; align-items: center; color: var(--ink); }
.brand:hover { color: var(--ink); }

/* Height-driven so the wordmark keeps its aspect ratio at any size. */
.brand__logo {
    width: auto;
    height: 52px;

    /* A wordmark on transparency, so drop-shadow follows the letterforms.
       Kept tight and light: a logo is the one mark on a page that should look
       printed rather than floating, and a wide soft shadow under type reads as
       a mistake long before it reads as depth. */
    filter: drop-shadow(0 2px 4px rgba(20, 20, 43, .22));
}

/* ---- header wordmark ----------------------------------------------------
   Arrives first and arrives quickly. It sits above the hero, so anything
   slower than the headline would leave the page looking as if it had loaded
   out of order.

   Scoped to the header on purpose: the footer mark is far down the page and
   lazy-loaded, so animating it would fire while nobody is looking at it and
   flicker if it decoded late. */
.site-header .brand__logo {
    /* `backwards`, not `both`. A forwards fill would keep asserting the
       animation's final `transform: none` for the rest of the page's life,
       and an animated property outranks everything else in the cascade — the
       hover below would be silently dead. Ending the animation hands transform
       back to the rules underneath it. */
    animation: brand-in .95s var(--ease-entrance) backwards;
}

@keyframes brand-in {
    from { opacity: 0; transform: translateY(-8px) scale(.96); }
    to   { opacity: 1; transform: none; }
}

/* A hover the pointer can feel without the layout moving — the logo is the
   way back to the front page, and this is the only cue that says so. */
.brand__logo { transition: transform .35s var(--ease-entrance); }
.brand:hover .brand__logo { transform: scale(1.04); }

/*
 * The logo is deep purple and mid blue — legible on white, but it sinks into
 * the near-black footer. Knocking it out to solid white is the standard fix
 * and keeps the mark readable there.
 */
/* The shadow has to be restated here, not inherited. `filter` is one property
   holding a list, so this declaration replaces the drop-shadow above outright
   rather than adding to it — writing only the knock-out would silently strip
   the footer logo's shadow.
   Darker and wider than the header's, because this one is white type on a
   near-black ground where a 22% shadow is invisible. */
.brand--light .brand__logo {
    filter: brightness(0) invert(1) drop-shadow(0 2px 6px rgba(0, 0, 0, .55));
    height: 46px;
}

/* Header "Masuk" button: one flat colour, taken from the wordmark's purple.
   Flat rather than the logo gradient — the CTA banner carries that, and two
   gradients competing on one page cheapens both. */
/* Colour and hover come from .btn--primary now — this used to restate them
   because the primary button was a gradient in a different purple. */
.site-header__login { flex-shrink: 0; }

.nav-toggle {
    display: none;
    padding: 8px;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    background: #fff;
    color: var(--ink);
}
.nav-toggle__close { display: none; }
.nav-toggle[aria-expanded="true"] .nav-toggle__open { display: none; }
.nav-toggle[aria-expanded="true"] .nav-toggle__close { display: block; }

.site-nav { display: flex; align-items: center; gap: 34px; }

.site-nav__list { display: flex; align-items: center; gap: 30px; }

.site-nav__link {
    position: relative;
    display: inline-block;
    padding: 6px 0;
    color: var(--ink-soft);
    font-size: var(--fs-sm);
    font-weight: 500;
}
.site-nav__link::after {
    content: "";
    position: absolute;
    left: 50%;
    bottom: -2px;
    width: 0;
    height: 2px;
    border-radius: 2px;
    background: var(--purple-600);
    transform: translateX(-50%);
    transition: width .25s var(--ease);
}
.site-nav__link:hover { color: var(--purple-700); }
.site-nav__link:hover::after { width: 60%; }

.site-nav__link.is-active { color: var(--purple-700); font-weight: 600; }
.site-nav__link.is-active::after { width: 100%; }

/* the little dot under the active item, as in the design */
.site-nav__link.is-active::before {
    content: "";
    position: absolute;
    left: 50%;
    bottom: -12px;
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background: var(--purple-600);
    transform: translateX(-50%);
}

/* --------------------------------------------------------------------------
   5. Section furniture
   -------------------------------------------------------------------------- */
.section { padding: clamp(48px, 4.5vw, 74px) 0; }
.section--soft { background: var(--surface-soft); }

/* The cooler ground. Hairlines top and bottom because the tint is light
   enough that, against white, its edges would otherwise be guesswork. */
.section--tint {
    background: var(--surface-tint);
    border-top: 1px solid var(--surface-tint-line);
    border-bottom: 1px solid var(--surface-tint-line);
}
.section--tight { padding-block: clamp(40px, 5vw, 60px); }

/* Full-bleed coloured band. Headings carry their own colour in the base
   rules, so they are handed back to `inherit` here — otherwise dark ink
   would sit on the blue. */
/* The news band, and the one section that gets a wash of its own.
 *
 * It sits between the hero and the benefits, and both of those are pale — the
 * hero fades to white at its foot and `--surface-soft` (#FBFAFF) is white with
 * a rumour of violet in it. Using either would have run the three together into
 * one long stretch of near-white with headings floating in it.
 *
 * A shade deeper and warmer than `--surface-soft`, with hairlines top and
 * bottom the way `--section--tint` has them, so the band has actual edges
 * rather than fading into its neighbours. */
.section--news {
    background: #F3F1FC;
    border-top: 1px solid #E7E3F7;
    border-bottom: 1px solid #E7E3F7;
}

.section--brand { background: var(--sky-500); color: #fff; }
.section--brand :is(h1, h2, h3, h4) { color: inherit; }

.section-head { max-width: 680px; margin: 0 auto clamp(32px, 5vw, 52px); text-align: center; }
.section-head--left { margin-inline: 0; text-align: left; }

.section-head__eyebrow {
    display: block;
    margin-bottom: 10px;
    color: var(--purple-600);
    font-size: var(--fs-xs);
    font-weight: 600;
    letter-spacing: .06em;
}

.section-head__title { margin-bottom: 14px; }

.section-head__lead { color: var(--body); font-size: var(--fs-md); }

/* The rule under centred headings: two gradients that meet in the middle, so
   the line is strongest at its centre and fades out at both ends.

   It used to carry a dot between them. The dot is gone site-wide — both the
   markup and its style — so a stray `<span class="rule__dot">` copied into a
   new view from anywhere would now render nothing at all. The gap goes with
   it, or the line would keep a notch where the dot used to sit. */
.rule {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0;
    margin: 16px auto 0;
    width: 120px;
}
.rule::before,
.rule::after {
    content: "";
    height: 2px;
    flex: 1;
    border-radius: 2px;
    background: linear-gradient(90deg, transparent, var(--purple-300, var(--purple-200)));
}
.rule::after { background: linear-gradient(90deg, var(--purple-200), transparent); }

/* --------------------------------------------------------------------------
   6. Hero
   -------------------------------------------------------------------------- */
/* ---- the banner wash ----------------------------------------------------
   Two pale pools over a vertical fade: violet a fifth of the way across, pink
   at the middle, and the right-hand side left as bare tint. The hero had no
   background at all before this — the section was transparent over plain
   white.

   The numbers are measured, not chosen. Sampling public/contoh-gradient.png
   gives #E8E0FE under the violet and #F5DFF3 under the pink against a #F6F4FF
   ground, which works out at roughly .19 and .16 alpha; the right-hand third
   reads #F7F5FF, i.e. no third pool at all. An earlier attempt guessed at
   three heavier pools and came out visibly darker and lopsided.

   Percentages throughout, so the arrangement holds from a phone to a wide
   desktop instead of crowding into a corner. Centres sit slightly above the
   box (-5%) so the band opens at its fullest colour and fades downward. */
.hero,
.page-head {
    background:
        radial-gradient(50% 130% at 18% -5%, rgba(139, 92, 246, .19), transparent 72%),
        radial-gradient(40% 115% at 50% -5%, rgba(236, 72, 153, .16), transparent 72%),
        linear-gradient(180deg, #F6F4FF 0%, #FBFAFF 55%, #fff 100%);
}

.hero {
    position: relative;
    padding: clamp(40px, 6vw, 72px) 0 clamp(48px, 7vw, 88px);
    overflow: hidden;

    /* The blending floor for the watercolour splash inside.
       It makes this element the backdrop the splash multiplies against — its
       own wash — and stops the blend reaching anything further down the page. */
    isolation: isolate;
}

/* No `position: relative; z-index: 1` here, and that is not an oversight.
   Either one makes this a new stacking context, and the watercolour splash
   inside it blends with `mix-blend-mode: multiply` — which needs to reach the
   hero's own background to work. Isolated, its white paper stops disappearing
   and shows up as a solid white rectangle over the photograph.

   The wash does not need it: it is a background on .hero, and backgrounds
   always paint behind their element's content. */
.hero__grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.18fr);
    align-items: center;
    gap: clamp(32px, 5vw, 56px);
}

.hero__title {
    margin-bottom: 22px;
    font-size: var(--fs-4xl);

    /* Montserrat Bold. `font-synthesis: none` keeps a browser from faking the
       weight by smearing the outline if the file has not arrived yet — the
       fallback's own bold is cleaner than a synthetic one. */
    font-weight: 700;
    font-synthesis: none;

    /* Three lines of display type: 1.15 holds them together without the
       descenders of one line touching the caps of the next. */
    line-height: 1.15;
    letter-spacing: -.02em;

    /* Dark type on a pale wash, so the shadow is a lift rather than a glow:
       a short offset and a tight blur, both well under 10% opacity.

       It has to stay this light. Anything heavier turns display type at this
       size muddy — the shadow starts filling the counters of the letterforms
       and the whole headline reads slightly out of focus. */
    text-shadow: 0 2px 4px rgba(20, 20, 43, .09);
}

/* The reference headline breaks after "Semua," and again after "untuk".
   Character-count limits (max-width in `ch`) only approximate that, so the
   breaks are explicit in the markup and simply switched off once the column
   is narrow enough to need its own wrapping. */
@media (max-width: 900px) {
    .hero__title br { display: none; }
}

/* Set in all caps, so it takes positive tracking: capitals are drawn to sit
   in lower-case words, and left at the headline's negative tracking they jam
   together. This is the one place on the page that opens up rather than in. */
.hero__title-mark {
    letter-spacing: .06em;

    /* Tracking adds space after the final letter too; pulling it back keeps
       the word optically centred against the lines above. */
    margin-right: -.06em;
}

.hero__lead {
    max-width: 40ch;
    margin-bottom: 32px;
    font-size: var(--fs-md);
    color: var(--body);

    /* Open Sans Regular — the sub-headline stays plain against the bold
       Montserrat above it. */
    font-weight: 400;

    /* Lighter again than the headline's. This is body-weight text at body
       size, where a shadow sized for display type would read as a printing
       fault rather than as depth. */
    text-shadow: 0 1px 3px rgba(20, 20, 43, .07);
}
.hero__actions { display: flex; flex-wrap: wrap; gap: 14px; }

/* ---- hero copy entrance -------------------------------------------------
   Headline, then subtitle, then the buttons — each a fifth of a second behind
   the last. Reading order and arrival order are the same, so the eye is led
   down the block instead of having to choose where to start.

   Only on arrival: none of these keep moving afterwards. The photograph and
   the splash carry the hero's idle motion, and a headline that drifts is a
   headline that is hard to read. */
.hero__title { animation: hero-copy-in 1.15s var(--ease-entrance) .1s both; }
.hero__lead  { animation: hero-copy-in 1.15s var(--ease-entrance) .34s both; }
.hero__actions { animation: hero-copy-in 1.15s var(--ease-entrance) .58s both; }

@keyframes hero-copy-in {
    from { opacity: 0; transform: translateY(16px); }
    to   { opacity: 1; transform: none; }
}

/* "MAJU" lands last and lands differently — a shade of scale on top of the
   line's own rise, so the wordmark reads as the point the sentence arrives at
   rather than just its final two syllables. */
.hero__title-mark {
    display: inline-block;
    animation: hero-mark-in .95s var(--ease-entrance) .78s both;
}

@keyframes hero-mark-in {
    from { opacity: 0; transform: translateY(8px) scale(.94); }
    to   { opacity: 1; transform: none; }
}

/* ---- hero artwork: photograph fused into the watercolour ----------------
   Two layers, combined entirely in CSS. The paint is a supplied image at
   public/assets/img/hero-splash.png; nothing here is generated.

     .hero__splash   the watercolour, sitting behind, with
                     `mix-blend-mode: multiply`.

     .hero__photo    the photograph, in front of the paint. Its rectangle is
                     dissolved by a mask made of four overlapping radial
                     gradients — their union is an irregular, soft-edged blob,
                     so no straight edge survives to give away that it was
                     ever a rectangle, and the photo melts into the splash at
                     its edges instead of sitting on top of it as a cut-out.

   Multiply on the splash is what lets the artwork be used uncut. Multiplying
   by white leaves the backdrop untouched, so the white paper the splash was
   painted on simply vanishes against the white page; only the pigment shows.

   Because the photo is in front, its colours stay true — the paint frames it
   rather than tinting it. Works the same whether the supplied artwork has a
   white background or a transparent one.
   ------------------------------------------------------------------------ */
.hero__media {
    position: relative;
    aspect-ratio: 4 / 3;

    /* No white panel and no isolation here any more.

       This box used to paint itself white and cut blending off at its own
       edge, which was right while the hero was white too — the panel was
       invisible and the splash's paper disappeared into it. With a tinted wash
       behind the hero, that same panel reads as a white rectangle sitting on
       the colour.

       The isolation moved up to .hero instead, so the splash multiplies
       against the wash itself: white paper leaves the tint untouched, pigment
       sinks into it. Blending still cannot escape the hero. */
}

.hero__photo {
    position: absolute;

    /* Inset so the paint reads all the way round the subject. */
    inset: 6% 8% 4%;

    /* In front of the paint. */
    z-index: 2;

    /* No frame, no rounding, no shadow: the photo is a cut-out with a
       transparent background, so the subject stands free against the paint.
       A box here would put a white card back on top of the artwork — which is
       exactly what the cut-out exists to avoid. */
}

.hero__photo img {
    width: 100%;
    height: 100%;

    /* `contain`, not `cover` — cover would crop the subject to fill the box. */
    object-fit: contain;
    object-position: center bottom;

    /* Grounds the cut-out against the paint so it does not look pasted on.

       Stacked like the artwork token: a wide pool for the lift and a tight one
       under the contact edge. The subject is standing on the splash, and a
       single soft shadow left it hovering a centimetre above the paper. */
    filter:
        drop-shadow(0 22px 30px rgba(20, 20, 43, .26))
        drop-shadow(0 4px 8px rgba(20, 20, 43, .16));

    /* The subject arrives first and the paint is thrown in behind them.

       Ordering the hero this way makes the photograph the thing the eye lands
       on and the splash its backdrop — the reverse, paint first, made the
       artwork the event and the person its passenger.

       Floats on a nine-second cycle against the splash's fourteen. The two
       never line up, which is what stops the pair looking like one object
       wobbling. */
    animation:
        hero-photo-in 1.35s var(--ease-entrance) .1s both,
        hero-photo-float 9s ease-in-out 1.45s infinite;
}

@keyframes hero-photo-in {
    from { opacity: 0; transform: translateY(22px) scale(.985); }
    to   { opacity: 1; transform: none; }
}

@keyframes hero-photo-float {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(-9px); }
}

.hero__splash {
    position: absolute;
    inset: 0;
    z-index: 1;
    width: 100%;
    height: 100%;
    max-width: none;
    object-fit: contain;
    mix-blend-mode: multiply;
    pointer-events: none;

    /* Two motions, and the order matters.

       The photograph settles first; the paint is thrown in behind it half a
       second later, once there is a subject for it to land around. The `both`
       fill is what makes the wait work — the artwork holds at opacity 0
       through the delay instead of sitting there waiting to be animated.

       After the burst it breathes: fourteen seconds for a cycle, and less than
       two per cent of scale. It is meant to be felt rather than watched;
       anything faster turns the hero into something that keeps asking to be
       looked at.

       `transform-origin` sits low and central because that is where the
       heaviest pigment lies, so the spread reads as coming from the paint
       rather than from the middle of an empty box. */
    transform-origin: 50% 58%;
    animation:
        splash-burst 1.9s cubic-bezier(.16, 1, .3, 1) .75s both,
        splash-breathe 14s ease-in-out 2.65s infinite;
}

/* Scaling the whole image up from 90% was never going to read as a splash —
   that is a picture getting bigger, and the eye knows the difference. Paint
   thrown at paper does three things at once, so this does all three:

     clip-path  the wet edge travelling outward. This is the one that actually
                sells it: pigment is *revealed* across the paper from the point
                of impact instead of the finished artwork fading up in place.
     transform  landing hard and small, overshooting past full size, then
                settling back — the recoil of something that hit rather than
                appeared.
     blur       out of focus for the first third, because a fast-moving edge
                has no crisp outline until it stops.

   The circle finishes at 140%, well past the corners, so the artwork is never
   left clipped once the animation's forwards fill takes hold. */
/* The drop-shadow is carried inside the keyframes, not declared on the element.
   It has to be: an animation that touches `filter` owns the property outright
   while it runs, and this one's forwards fill owns it for good afterwards — a
   `filter: drop-shadow(...)` on `.hero__splash` itself would be overwritten at
   frame one and never seen again.

   It is deliberately light. The artwork has no hard edge anywhere — the alpha
   fades out over a hundred-odd pixels — so `drop-shadow` has nothing crisp to
   trace and returns a soft haze rather than an outline. Multiply then darkens
   that haze into the wash. Enough to give the paint some weight is all this can
   honestly be; pushed further it turns the hero grey. */
@keyframes splash-burst {
    0% {
        opacity: 0;
        transform: scale(.42) rotate(-9deg);
        clip-path: circle(5% at 50% 58%);
        filter: blur(5px) drop-shadow(0 10px 16px rgba(20, 20, 43, .16));
    }
    30% {
        opacity: 1;
        filter: blur(2px) drop-shadow(0 12px 18px rgba(20, 20, 43, .16));
    }
    62% {
        transform: scale(1.07) rotate(2deg);
        clip-path: circle(80% at 50% 58%);
        filter: blur(0) drop-shadow(0 14px 20px rgba(20, 20, 43, .16));
    }
    82% {
        transform: scale(.985) rotate(-.6deg);
    }
    100% {
        opacity: 1;
        transform: none;
        clip-path: circle(140% at 50% 58%);
        filter: blur(0) drop-shadow(0 14px 20px rgba(20, 20, 43, .16));
    }
}

/* The shockwave: one ring pushed out from the point of impact, gone in a
   second and a half and never seen again.

   It sits *behind* the artwork on purpose. The splash multiplies, and multiply
   against white leaves the backdrop alone — so the ring shows through the bare
   paper and is swallowed by the pigment, exactly as it would be if the wave
   were travelling under the paint rather than over it.

   `translate`/`scale` as their own properties, not `transform` — the shorthand
   here would have to restate the centring offset in every keyframe, and one
   typo would slide the ring off the impact point. */
.hero__media::before {
    content: "";
    position: absolute;
    left: 50%;
    top: 58%;
    width: 32%;
    aspect-ratio: 1;
    translate: -50% -50%;
    border-radius: 50%;
    border: 2px solid rgba(139, 92, 246, .45);
    pointer-events: none;
    z-index: 0;
    /* Rides with the burst, a shade ahead of it — the wave leads the pigment. */
    animation: splash-ring 2s cubic-bezier(.16, 1, .3, 1) .7s both;
}

@keyframes splash-ring {
    0%   { opacity: 0;   scale: .15; }
    15%  { opacity: .6; }
    100% { opacity: 0;   scale: 2.6; }
}

@keyframes splash-breathe {
    0%, 100% { transform: scale(1)     rotate(0deg); }
    50%      { transform: scale(1.018) rotate(.4deg); }
}

/* No artwork on disk: show the photo on its own, filling the frame. */
.hero__media--plain { aspect-ratio: 1 / 1; }
.hero__media--plain .hero__photo { inset: 0; }

/* --------------------------------------------------------------------------
   7. Program cards / benefit grid
   -------------------------------------------------------------------------- */
.card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
    gap: 20px;
}
.card-grid--3 { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }

.program-card {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 14px;
    padding: 30px 22px;
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm);
    text-align: center;
    transition: transform .28s var(--ease), box-shadow .28s var(--ease), border-color .28s var(--ease);
}
.program-card:hover {
    transform: translateY(-6px);
    border-color: var(--purple-200);
    box-shadow: var(--shadow-lg);
}

.program-card__icon {
    display: grid;
    place-items: center;
    width: 58px;
    height: 58px;
    border-radius: 50%;
    background: var(--purple-100);
    color: var(--purple-600);
}
.program-card__icon[data-tone="orange"] { background: var(--orange-100); color: var(--orange-500); }
.program-card__icon[data-tone="blue"]   { background: var(--blue-100);   color: var(--blue-500); }
.program-card__icon[data-tone="pink"]   { background: #FCE7F3;           color: var(--pink-500); }

.program-card__title { margin: 0; font-size: var(--fs-lg); }
.program-card__text  { font-size: var(--fs-sm); line-height: 1.66; color: var(--body); }

.program-card__link {
    margin-top: auto;
    padding-top: 6px;
    font-size: var(--fs-sm);
    font-weight: 600;
    display: inline-flex;
    align-items: center;
    gap: 6px;
}
.program-card__link .icon { width: 16px; height: 16px; transition: transform .2s var(--ease); }
.program-card__link:hover .icon { transform: translateX(3px); }

/* ---- benefit grid: illustration beside copy, two per row ----------------
   No card, no border: the reference design sets the four points straight on
   the page and lets the artwork do the separating. Each item is its own
   two-column grid so the text column starts at the same offset in every
   cell, whatever height the illustration turns out to be.
   ------------------------------------------------------------------------ */
.benefit-grid {
    display: grid;
    /* `min(400px, 100%)` rather than a bare 400px: on a phone narrower than
       the track the column would otherwise keep its 400px and push the page
       into a sideways scroll. */
    grid-template-columns: repeat(auto-fit, minmax(min(400px, 100%), 1fr));

    /* Generous row gap: the rows carry no divider, so the space is what
       tells them apart. */
    gap: clamp(28px, 4vw, 52px) clamp(24px, 4vw, 64px);
    margin: 0;
    padding: 0;
    list-style: none;
}

.benefit {
    display: grid;
    grid-template-columns: clamp(120px, 15vw, 190px) minmax(0, 1fr);
    align-items: center;
    gap: clamp(16px, 2.5vw, 32px);
}

/* The artwork lifts, not the whole row: moving the text as well would nudge
   the reader's line, which is the one thing on the page that should hold
   still. Transition lives on the image so it eases both ways. */
.benefit__media img { transition: transform .35s var(--ease), filter .35s var(--ease); }

/* The illustration rises and its shadow deepens with it. Both, or the lift
   reads as the picture sliding up rather than coming off the page. */
.benefit:hover .benefit__media img {
    transform: translateY(-6px) scale(1.03);
    filter: var(--art-shadow);
}

.benefit__media {
    display: grid;
    place-items: center;
    aspect-ratio: 1 / 1;
}
.benefit__media img {
    width: 100%;
    height: 100%;

    /* `contain` — illustrations arrive at their own proportions and must not
       be cropped to a square. */
    object-fit: contain;

    /* Cut-outs on transparency, so a drop-shadow that follows the subject —
       not a box-shadow, which would draw a rectangle around empty space. The
       small token: these sit at 120–190px, the same range as the flow steps. */
    filter: var(--art-shadow-sm);
}

/* Stand-in while an illustration is missing: the same tinted disc the
   program cards use, sized to hold the illustration's place. The modifier is
   set in PHP from what is actually on disk, so the fallback never depends on
   the browser resolving a :has() selector. */
/* The fallback when no illustration is on disk: a tinted disc holding an icon.
   This one is a filled shape, so it takes a real box-shadow — and it must not
   also inherit the drop-shadow above, which would trace the disc a second
   time and double the weight. */
.benefit__media--icon {
    width: clamp(84px, 10vw, 110px);
    justify-self: center;
    border-radius: 50%;
    background: var(--purple-100);
    color: var(--purple-600);
    box-shadow: 0 8px 18px rgba(20, 20, 43, .16), 0 2px 5px rgba(20, 20, 43, .10);
}
.benefit__media--icon img { filter: none; }
.benefit__media--icon[data-tone="orange"] { background: var(--orange-100); color: var(--orange-500); }
.benefit__media--icon[data-tone="blue"]   { background: var(--blue-100);   color: var(--blue-500); }
.benefit__media--icon[data-tone="pink"]   { background: #FCE7F3;           color: var(--pink-500); }

.benefit__title { margin: 0 0 10px; font-size: var(--fs-lg); }

.benefit__text {
    margin: 0;
    max-width: 34ch;
    font-size: var(--fs-sm);
    line-height: 1.8;
    color: var(--body);
}

/* --------------------------------------------------------------------------
   8. About split
   -------------------------------------------------------------------------- */
.about__grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-items: center;
    gap: clamp(32px, 5vw, 64px);
}

.value-list { display: grid; gap: 12px; margin-top: 26px; }
.value-list li {
    display: flex;
    align-items: center;
    gap: 11px;
    font-size: var(--fs-sm);
    font-weight: 500;
    color: var(--ink-soft);
}
.value-list .icon { color: var(--purple-600); }

/* Photo in a slightly tilted frame, with a coloured card peeking out behind
   it — offset down and to the right, as in the reference design.
   Capped so a wide viewport does not blow the photo up until it dwarfs the
   text column beside it. */
.frame {
    position: relative;
    width: 100%;
    max-width: 540px;
    margin-inline: auto;
}
.frame::before {
    content: "";
    position: absolute;
    inset: 8% -6% -9% 10%;
    border-radius: var(--radius-xl);
    background: linear-gradient(135deg, var(--pink-500), var(--purple-600) 52%, #6366F1);
    opacity: .9;
}
.frame__inner {
    position: relative;
    border-radius: var(--radius-lg);
    overflow: hidden;
    box-shadow: var(--shadow-lg);
    aspect-ratio: 4 / 3;
    transform: rotate(-1.6deg);
}
.frame__inner img { width: 100%; height: 100%; object-fit: cover; }

/* ---- intro band: "Apa itu Agen Digital MAJU?" ---------------------------
   A full-bleed coloured band with the EDC render on the left and the
   explanation on the right. The colour comes from `.section--brand`, so the
   band is a section modifier rather than something baked into this block.
   ------------------------------------------------------------------------ */
.intro-band {
    display: grid;

    /* The device is narrow and tall; giving the text roughly twice the width
       keeps the paragraphs at a readable measure instead of a thin ribbon. */
    grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
    align-items: center;
    gap: clamp(28px, 5vw, 72px);
}

.intro-band__media { display: grid; place-items: center; }

/* The device comes in from its own side of the band rather than straight up
   like everything else, so the band opens outward from the middle instead of
   scrolling up as one sheet.

   This has to sit above `.reveal.is-visible` in the file, not below it: both
   selectors weigh the same, so whichever is written last wins. Moved below it,
   this rule would pin the device permanently off-centre. */
@media (scripting: enabled) {
    .intro-band__media.reveal { transform: translateX(-34px) translateY(8px); }
}

.intro-band__media img {
    /* Capped so a wide viewport does not scale a phone-sized device up to
       the height of the whole band. */
    max-height: min(60vh, 480px);
    width: auto;

    /* The render is a cut-out on transparency: the shadow is what stops it
       floating flat on the colour. Deeper than the shared token because this
       one sits on saturated blue, where the standard weight disappears. */
    filter: drop-shadow(0 20px 34px rgba(20, 20, 43, .28));

    /* Idle drift lives on the image, never on `.intro-band__media` — the
       wrapper's transform belongs to the reveal, and an infinite animation on
       the same property would overrule the transition and strand it. */
    animation: intro-device-float 7s ease-in-out infinite;
}

@keyframes intro-device-float {
    0%, 100% { transform: translateY(0)    rotate(0deg); }
    50%      { transform: translateY(-10px) rotate(-.8deg); }
}

.intro-band__title { margin-bottom: 22px; font-size: var(--fs-3xl); }

.intro-band__body p { margin-bottom: 18px; line-height: 1.85; }
.intro-band__body p:last-child { margin-bottom: 0; }
.intro-band__body strong { font-weight: 700; }

/* ---- supported transactions --------------------------------------------
   Two cards, each holding a list that runs into two columns once there is
   room. The lists are different lengths on purpose — payments outnumber
   banking services — so the cards are top-aligned rather than stretched to
   match, which would leave a pool of empty white under the shorter one.
   ------------------------------------------------------------------------ */
.txn {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr));
    align-items: start;
    gap: clamp(18px, 2.5vw, 26px);
}

.txn__group {
    padding: clamp(24px, 3vw, 32px);
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-sm);
    transition: transform .3s var(--ease), box-shadow .3s var(--ease), border-color .3s var(--ease);
}
.txn__group:hover {
    transform: translateY(-6px);
    border-color: var(--purple-200);
    box-shadow: var(--lift-1);
}

.txn__head {
    display: flex;
    align-items: center;
    gap: 14px;
    margin-bottom: clamp(18px, 2vw, 24px);
}

.txn__icon {
    display: grid;
    place-items: center;
    flex-shrink: 0;
    width: 46px;
    height: 46px;
    border-radius: 14px;
    background: var(--purple-100);
    color: var(--purple-600);
}
.txn__icon[data-tone="orange"] { background: var(--orange-100); color: var(--orange-500); }
.txn__icon[data-tone="blue"]   { background: var(--blue-100);   color: var(--blue-500); }

.txn__title { margin: 0; font-size: var(--fs-lg); }

.txn__list {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(230px, 100%), 1fr));
    gap: 12px 22px;
}

.txn__list li {
    display: flex;
    align-items: flex-start;
    gap: 9px;
    font-size: var(--fs-sm);
    line-height: 1.55;
    color: var(--body);
}

/* Nudged onto the first line of text rather than the top of the box. */
.txn__list .icon { margin-top: 2px; color: var(--purple-600); }

/* ---- accepted schemes and certification marks ---------------------------
   A quiet band under the two cards. Real logo files sit at their own size;
   until one is supplied its scheme is set as a wordmark chip, so the row
   stays even either way rather than showing a hole where a logo will go.
   ------------------------------------------------------------------------ */
.logo-strip {
    margin-top: clamp(20px, 2.5vw, 28px);
    padding: clamp(20px, 2.5vw, 28px);
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    text-align: center;
}

.logo-strip__label {
    margin-bottom: 16px;
    color: var(--muted);
    font-size: var(--fs-xs);
    font-weight: 600;
    letter-spacing: .08em;
    text-transform: uppercase;
}

.logo-strip__list {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: 12px 16px;
}

.logo-strip__item img {
    /* Height-matched, width free: the schemes' logos are all different
       proportions and forcing a common width would squash half of them. */
    height: 32px;
    width: auto;

    /* Much lighter than the artwork token. These are payment-scheme marks and
       a heavy shadow under a Visa logo looks like a mistake — this is just
       enough to lift them off the strip. */
    filter: drop-shadow(0 3px 5px rgba(20, 20, 43, .18));
}

.logo-strip__chip {
    display: inline-block;
    padding: 9px 18px;
    border: 1px solid var(--line);
    border-radius: 999px;
    background: var(--surface-soft);
    color: var(--ink-soft);
    font-size: var(--fs-xs);
    font-weight: 700;
    letter-spacing: .08em;
    text-transform: uppercase;
    white-space: nowrap;
    transition: transform .25s var(--ease), border-color .25s var(--ease),
                background .25s var(--ease), color .25s var(--ease);
}

/* The chips are the one row on the page that sat completely still. A small
   lift on hover is enough to say they are a set of real marks rather than
   printed decoration. */
.logo-strip__chip:hover {
    transform: translateY(-3px);
    border-color: var(--purple-200);
    background: var(--surface);
    color: var(--logo-purple);
}

/* The certification marks are a footnote to the schemes above, not a second
   row of equals — so they are quieter and sit behind a hairline. */
.logo-strip__list--marks {
    margin-top: 18px;
    padding-top: 18px;
    border-top: 1px solid var(--line);
    gap: 10px 26px;
}
.logo-strip__list--marks .logo-strip__item img { height: 28px; }

.logo-strip__mark {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    color: var(--muted);
    font-size: var(--fs-xs);
    font-weight: 600;
}
.logo-strip__mark .icon { color: var(--purple-400); }

/* ---- news: the single headline under the hero ---------------------------
   One story, laid out as a wide card: picture on the left, the story on the
   right. Deliberately not the same shape as `.post-card` — that is a tile in a
   grid of equals, and this is the one item on the page that is meant to be
   read before anything below it.
   ------------------------------------------------------------------------ */
.news {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
    align-items: center;
    gap: clamp(20px, 3.5vw, 44px);

    padding: clamp(18px, 2.5vw, 26px);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    background: var(--surface);

    /* No shadow at rest — it arrives on hover, below.
       The border is what carries the card at rest, and it has to stay: without
       either, a white card on the news band's pale wash would have no edge at
       all until the pointer found it. */
    transition: transform .3s var(--ease), box-shadow .3s var(--ease);
}

.news:hover {
    transform: translateY(-4px);
    box-shadow: 0 18px 36px rgba(20, 20, 43, .16), 0 4px 10px rgba(20, 20, 43, .09);
}

/* No picture in the file: the story takes the whole card rather than leaving a
   column open for an image that is not there. */
.news--plain { grid-template-columns: minmax(0, 1fr); }

.news__media {
    border-radius: var(--radius);
    overflow: hidden;
    aspect-ratio: 16 / 10;
    background: var(--purple-50);
}
.news__media img { width: 100%; height: 100%; object-fit: cover; }

.news__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 10px;
    margin: 0 0 12px;
}

.news__tag {
    padding: 4px 11px;
    border-radius: 999px;
    background: var(--accent-tint);
    color: var(--accent-violet-deep);
    font-family: var(--font-ui);
    font-size: var(--fs-2xs);
    font-weight: 700;
    letter-spacing: .04em;
    text-transform: uppercase;
}

.news__date { font-size: var(--fs-xs); color: var(--muted); }

.news__title {
    margin: 0 0 12px;
    font-size: var(--fs-2xl);
    line-height: 1.3;
}

.news__excerpt {
    margin: 0;
    color: var(--body);
    font-size: var(--fs-sm);
    line-height: 1.8;

    /* Clamped so a long entry cannot stretch the card taller than its own
       picture and leave the image floating in white space. */
    display: -webkit-box;
    -webkit-line-clamp: 4;
    line-clamp: 4;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.news__actions { margin: 20px 0 0; }

/* ---- the three under the lead ------------------------------------------
   Equals in a row, so a grid rather than the headline's two-column split.
   `auto-fit` with a floor means they drop to two and then one on their own
   instead of needing a breakpoint each.
   ------------------------------------------------------------------------ */
.news-more {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
    gap: clamp(14px, 2vw, 22px);
    margin: clamp(16px, 2.5vw, 24px) 0 0;
    padding: 0;
    list-style: none;
}

.news-card {
    display: flex;
    flex-direction: column;
    padding: 20px;
    border: 1px solid var(--line);
    border-radius: var(--radius);
    background: var(--surface);

    /* Flat at rest, like the headline card above — the border carries it. */
    transition: transform .3s var(--ease), box-shadow .3s var(--ease);
}

/* The `:has(a)` gate is gone: the shadow now answers the pointer on every card
   in this band, linked or not, because that is what was asked for. The lift is
   kept small for the same reason it was once withheld — with no article route
   yet most of these open nothing, and a card that leaps under the pointer
   promises more than the page can keep. */
.news-card:hover,
.news-card:focus-within {
    transform: translateY(-4px);
    box-shadow: 0 14px 28px rgba(20, 20, 43, .16), 0 3px 8px rgba(20, 20, 43, .09);
}

.news-card__media {
    margin: -20px -20px 16px;
    border-radius: var(--radius) var(--radius) 0 0;
    overflow: hidden;
    aspect-ratio: 16 / 10;
    background: var(--purple-50);
}
.news-card__media img { width: 100%; height: 100%; object-fit: cover; }

.news-card__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    margin: 0 0 10px;
}

.news-card__title {
    margin: 0 0 8px;
    font-size: var(--fs-md);
    line-height: 1.45;
}
.news-card__title a { color: inherit; }
.news-card__title a:hover { color: var(--logo-purple); }

.news-card__text {
    margin: 0;
    color: var(--body);
    font-size: var(--fs-xs);
    line-height: 1.75;

    /* Three lines, so a long entry cannot make one card taller than the two
       beside it. */
    display: -webkit-box;
    -webkit-line-clamp: 3;
    line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

/* The picture goes above the text rather than beside it, and loses some height
   so it does not push the headline off the first screenful. */
@media (max-width: 800px) {
    .news { grid-template-columns: minmax(0, 1fr); }
    .news__media { aspect-ratio: 16 / 9; }
    .news__title { font-size: var(--fs-xl); }
}

/* ---- estimate panel: a worked example of a day's takings ----------------
   Heading and lead run the full width; below them the takings sit on the
   left and the artwork on the right. The figures get a column of their own
   so they can be compared down the page — run into prose, as the reference
   copy does, they are impossible to scan.
   ------------------------------------------------------------------------ */
.estimate {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, .82fr);

    /* The heading takes only the height it needs; the list below it takes
       the rest. Naming the rows is what lets the artwork span both. */
    grid-template-rows: auto 1fr;
    align-items: start;
    gap: clamp(24px, 4vw, 56px);
}

.estimate__head {
    grid-column: 1;
    max-width: 68ch;
}

.estimate__body { grid-column: 1; }

.estimate__title { margin-bottom: 14px; font-size: var(--fs-3xl); }
.estimate__lead { margin: 0; line-height: 1.85; color: var(--body); }

/* Rows sit on white and are told apart by the colour of their amount, not by
   a stripe behind them — the colours below are sampled from the reference.
   The block is clipped as one so only its outer corners round. */
.estimate__rows {
    margin: 0;
    max-width: 520px;
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    overflow: hidden;
}

.estimate__row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 24px;
    padding: 15px 18px;
    font-size: var(--fs-sm);
}

/* Dotted rules between the lines, receipt-style. Set on the following row
   rather than as a bottom border on every row, so the last line does not
   trail a rule against the edge of the block. Purple-200 rather than the
   hairline grey: dots have far less ink than a solid rule and disappear
   entirely at --line's lightness. */
.estimate__row + .estimate__row { border-top: 1px dotted var(--purple-200); }

/* Three accents, cycled: violet, blue, green. The label stays neutral so the
   colour lands on the figure, which is what the row is read for. */
.estimate__row:nth-child(3n+1) dd { color: var(--accent-violet); }
.estimate__row:nth-child(3n+2) dd { color: var(--accent-blue); }
.estimate__row:nth-child(3n+3) dd { color: var(--accent-green); }

.estimate__row dt { color: var(--body); }
.estimate__row dd {
    margin: 0;
    font-weight: 600;
    color: var(--ink);

    /* Digits of equal width, so the rupiah figures stack into a clean column
       instead of jittering row to row. */
    font-variant-numeric: tabular-nums;
}

/* The closing line is the one row that does take a band, so it reads as the
   end of the table rather than as another entry in it. Selector is heavier
   than the nth-child accents above so it wins without `!important`. */
.estimate__rows .estimate__row--total {
    padding-block: 18px;
    background: var(--accent-tint);
}
.estimate__rows .estimate__row--total dt { color: var(--ink); font-weight: 700; }
.estimate__rows .estimate__row--total dd {
    color: var(--accent-violet-deep);
    font-size: var(--fs-xl);
    font-weight: 700;
}

/* The rows above tick past one at a time; the month total is what they add up
   to, so it lands rather than slides — a shade of scale on top of the rise.

   Two specificity traps here, both silent if you get them wrong:

     - Exactly two classes, not three. `.estimate__rows .estimate__row--total`
       would outweigh `.reveal.is-visible` and pin the row at its start state
       for good — visibly squashed and offset, for ever.
     - No `transition-delay`. `.stagger > .reveal:nth-child(n)` is heavier and
       written later, so any delay set here is overruled anyway. It needs none:
       the total is the last child, so the stagger already lands it last. */
@media (scripting: enabled) {
    .estimate__row--total.reveal { transform: translateY(14px) scale(.97); }
}

.estimate__note {
    margin: 18px 0 0;
    max-width: 52ch;
    font-size: var(--fs-xs);
    color: var(--muted);
}

/* The artwork spans both rows — heading and list — so it starts at the very
   top of the section instead of below the heading, and is then centred in
   that full height. Confining it to the second row is what left the gap
   above it. */
.estimate__media {
    grid-column: 2;
    grid-row: 1 / -1;
    align-self: center;
    display: grid;
    place-items: center;
}
.estimate__media img {
    width: 100%;
    max-width: 460px;
    height: auto;
    filter: var(--art-shadow);
}

/* Mirrors the intro band: the artwork enters from its own side while the
   figures rise from below, so the panel opens rather than scrolls. Same
   two-class ceiling as the total row above, and same reason. */
@media (scripting: enabled) {
    .estimate__media.reveal { transform: translateX(30px) translateY(8px); }
}

.estimate__media img { animation: estimate-art-float 8s ease-in-out infinite; }

@keyframes estimate-art-float {
    0%, 100% { transform: translateY(0)   rotate(0deg); }
    50%      { transform: translateY(-8px) rotate(.7deg); }
}

/* No artwork on disk: the body takes the full width rather than leaving a
   column open for a picture that is not there. */
.estimate--plain { grid-template-columns: minmax(0, 1fr); }

/* ---- videos and shorts --------------------------------------------------
   Two blocks side by side: three vertical shorts, then two landscape videos.
   Three 9:16 tiles and two 16:9 tiles come out at roughly the same height, so
   the two blocks finish level without either being forced into the other's
   proportions.

   The column split is 3fr / 2fr — the share of tiles in each block — so the
   tiles themselves end up about the same width across the whole row.
   ------------------------------------------------------------------------ */
/* ---- MAJU di FYP --------------------------------------------------------
   Three vertical cards: reels other people posted. Its own block rather than a
   reuse of `.video--short`, because the two answer different questions — the
   shorts row is the company's own catalogue and carries only a title, while
   these have to say plainly whose account they came from, or the section loses
   the one thing that makes it worth showing.
   ------------------------------------------------------------------------ */
.fyp {
    display: grid;
    /* 150px, not 220. At 220 a 390px phone fits exactly one column, and three
       9:16 cards stacked one per row make this section over 2000px tall — a
       reader has to scroll past three full screens of it to reach what comes
       next. At 150 a phone fits two, and desktop still lands on three because
       the row is capped at 860px below. */
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    gap: clamp(16px, 2.4vw, 26px);
    margin: 0;
    padding: 0;
    list-style: none;

    /* Three reels never want the full width of a desktop container — at
       1200px they stretch to portrait billboards. */
    max-width: 1040px;
    margin-inline: auto;
}

.fyp__link {
    display: grid;
    gap: 12px;
    text-decoration: none;
    color: inherit;
}

.fyp__thumb {
    position: relative;
    display: block;
    aspect-ratio: 9 / 16;
    border-radius: var(--radius);
    background: var(--purple-100);
    overflow: hidden;

    box-shadow:
        0 10px 22px rgba(20, 20, 43, .16),
        0 2px 5px rgba(20, 20, 43, .10);

    transition: transform .3s var(--ease), box-shadow .3s var(--ease);
}

.fyp__thumb img { width: 100%; height: 100%; object-fit: cover; }

/* The Instagram tile has no still — Instagram serves none to a visitor who is
   not signed in. Rather than a broken image or a borrowed frame, it gets the
   platform's own gradient, so it reads as deliberate. */
.fyp__thumb--instagram {
    background: linear-gradient(140deg, #405DE6 0%, #C13584 45%, #F56040 75%, #FCAF45 100%);
}

.fyp__link:hover .fyp__thumb,
.fyp__link:focus-visible .fyp__thumb {
    transform: translateY(-6px);
    box-shadow:
        0 20px 36px rgba(20, 20, 43, .24),
        0 4px 8px rgba(20, 20, 43, .12);
}

/* Same play mark as the video cards, drawn rather than an image so it reads on
   the still-less tile too. */
.fyp__play {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 46px;
    height: 46px;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    background: rgba(20, 20, 43, .55);
    backdrop-filter: blur(2px);
    box-shadow:
        0 6px 16px rgba(0, 0, 0, .38),
        inset 0 1px 0 rgba(255, 255, 255, .22);
    transition: background .25s var(--ease), transform .25s var(--ease), box-shadow .25s var(--ease);
}
.fyp__play::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 54%;
    width: 0;
    height: 0;
    transform: translate(-50%, -50%);
    border-left: 16px solid #fff;
    border-top: 10px solid transparent;
    border-bottom: 10px solid transparent;
}
.fyp__link:hover .fyp__play,
.fyp__link:focus-visible .fyp__play {
    background: var(--logo-purple);
    transform: translate(-50%, -50%) scale(1.08);
    box-shadow: 0 8px 20px rgba(102, 54, 150, .50);
}

/* Which platform, said on the tile itself. Without it the three cards look
   like one set from one place, and two of them are not. */
.fyp__badge {
    position: absolute;
    top: 10px;
    left: 10px;
    padding: 4px 9px;
    border-radius: 999px;
    background: rgba(20, 20, 43, .62);
    backdrop-filter: blur(3px);
    color: #fff;
    font-size: var(--fs-2xs);
    font-weight: 700;
    letter-spacing: .02em;
}

/* The MAJU mark. A white pill, because the logo is purple on transparent and
   would vanish into the dark half of a photograph on its own. Opposite corner
   from the platform badge so the two read as two labels, not one. */
.fyp__brand {
    position: absolute;
    top: 10px;
    right: 10px;
    display: grid;
    place-items: center;
    padding: 5px 9px;
    border-radius: 999px;
    background: rgba(255, 255, 255, .94);
    box-shadow: 0 2px 8px rgba(20, 20, 43, .22);
}
.fyp__brand img { display: block; width: auto; height: 15px; }

/* On the Instagram tile there is no photograph to identify, so the mark also
   stands in for one: a larger version, centred above the play button, which
   turns an empty gradient into something that says whose section this is. */
.fyp__thumb--instagram .fyp__brand {
    top: 50%;
    left: 50%;
    right: auto;
    transform: translate(-50%, calc(-50% - 52px));
    padding: 9px 16px;
}
.fyp__thumb--instagram .fyp__brand img { height: 26px; }

.fyp__body { display: grid; gap: 3px; }

.fyp__title {
    font-size: var(--fs-sm);
    font-weight: 600;
    color: var(--ink);
    line-height: 1.45;

    /* Two lines, then an ellipsis. These captions come from other people's
       accounts and can run to a paragraph of hashtags; letting one card grow
       to six lines drags the whole row out of alignment. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.fyp__author { font-size: var(--fs-2xs); color: var(--muted); }

.videos-split {
    display: grid;
    grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
    gap: clamp(26px, 4vw, 48px);
    align-items: start;
}

.videos-group__title {
    margin-bottom: 18px;
    font-size: var(--fs-lg);
    color: var(--ink);
}

.videos {
    display: grid;
    gap: clamp(14px, 2vw, 20px);
    align-items: start;
}
.videos--short { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.videos--video { grid-template-columns: repeat(2, minmax(0, 1fr)); }

.video__link {
    display: grid;
    gap: 12px;
    color: var(--ink);
}
.video__link:hover { color: var(--purple-700); }

.video__thumb {
    position: relative;
    display: block;
    aspect-ratio: 16 / 9;
    border-radius: var(--radius);
    background: var(--purple-100);
    overflow: hidden;

    /* Two layers: a wide soft pool for the lift, a tight dark one at the edge
       so the rounded corner has a defined boundary against the tinted section
       behind it. `--shadow-sm` used to do this alone — 1px at 5% opacity,
       which on a coloured ground is not a shadow, it is a rumour. */
    box-shadow:
        0 10px 22px rgba(20, 20, 43, .16),
        0 2px 5px rgba(20, 20, 43, .10);

    transition: transform .3s var(--ease), box-shadow .3s var(--ease);
}
.video--short .video__thumb { aspect-ratio: 9 / 16; }

.video__thumb img { width: 100%; height: 100%; object-fit: cover; }

/* The card rises and its shadow grows with it — the two have to move together
   or the lift reads as the picture sliding up the page rather than off it. */
.video__link:hover .video__thumb,
.video__link:focus-visible .video__thumb {
    transform: translateY(-6px);
    box-shadow:
        0 20px 36px rgba(20, 20, 43, .24),
        0 4px 8px rgba(20, 20, 43, .12);
}

/* The play mark is a CSS triangle on a disc, so it still reads on a card
   whose thumbnail failed to load. Sized in em of the tile so it stays in
   proportion on the narrower short tiles. */
.video__play {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 46px;
    height: 46px;
    transform: translate(-50%, -50%);
    border-radius: 50%;
    background: rgba(20, 20, 43, .55);
    backdrop-filter: blur(2px);

    /* The disc is translucent, so on a busy still it can sink into whatever is
       behind it. The shadow gives it an edge of its own; the inset highlight
       keeps the top of the circle visible against a dark frame. */
    box-shadow:
        0 6px 16px rgba(0, 0, 0, .38),
        inset 0 1px 0 rgba(255, 255, 255, .22);

    transition: background .25s var(--ease), transform .25s var(--ease), box-shadow .25s var(--ease);
}
.video__play::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 54%;
    width: 0;
    height: 0;
    transform: translate(-50%, -50%);
    border-left: 16px solid #fff;
    border-top: 10px solid transparent;
    border-bottom: 10px solid transparent;
}
.video__link:hover .video__play,
.video__link:focus-visible .video__play {
    background: var(--logo-purple);
    transform: translate(-50%, -50%) scale(1.08);

    /* Tinted to the button's own purple on hover, so the glow belongs to the
       control rather than looking like a second grey shadow. */
    box-shadow:
        0 8px 20px rgba(102, 54, 150, .50),
        inset 0 1px 0 rgba(255, 255, 255, .28);
}

.video__title {
    font-size: var(--fs-sm);
    font-weight: 600;
    line-height: 1.5;

    /* Titles vary from three words to a full sentence; clamping keeps the
       row of cards even without truncating mid-thought on the short ones. */
    display: -webkit-box;
    -webkit-line-clamp: 3;
    line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.videos__more { margin-top: clamp(24px, 3vw, 34px); text-align: center; }

/* ---- testimonials -------------------------------------------------------
   The quote is the subject, so it is set largest and first; the portrait is
   an attribution mark at the foot of the card, not a header image. Keeping
   the picture small also keeps the supplied circular crops honest — squared
   off at 56px, the scaling that costs their outer edge is invisible.

   Cards stretch to equal height and the attribution is pushed to the bottom,
   so the three names line up across the row however long the quotes run.
   ------------------------------------------------------------------------ */
.quotes {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
    gap: clamp(18px, 2.5vw, 26px);
    align-items: stretch;
}

.quote {
    display: flex;
    flex-direction: column;
    margin: 0;
    padding: clamp(26px, 3vw, 36px);
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-sm);
    transition: transform .3s var(--ease), box-shadow .3s var(--ease), border-color .3s var(--ease);
}
.quote:hover {
    transform: translateY(-6px);
    border-color: var(--purple-200);
    box-shadow: var(--lift-1);
}

/* A short rule rather than a giant serif quotation mark: the mark competed
   with the text for weight and pushed the first line down by 30px. */
.quote__text {
    flex: 1 1 auto;
    margin: 0 0 22px;
    color: var(--ink-soft);
    font-size: var(--fs-md);
    line-height: 1.75;
}
.quote__text::before {
    content: "";
    display: block;
    width: 34px;
    height: 3px;
    margin-bottom: 18px;
    border-radius: 2px;
    background: var(--grad-logo);
}
.quote__text p { margin: 0; }

.quote__by {
    display: flex;
    align-items: center;
    gap: 14px;
    padding-top: 20px;
    border-top: 1px solid var(--line);
}

/* Rounded square, not a disc — a circle here would put the round crop back,
   which is the shape that was rejected. */
.quote__portrait {
    display: grid;
    place-items: center;
    flex-shrink: 0;
    width: 56px;
    height: 56px;
    border-radius: 14px;
    background: var(--purple-50);
    overflow: hidden;

    /* `box-shadow`, not the art token: this one is a filled rounded box, so
       the shadow belongs to the frame rather than to the face inside it. */
    box-shadow: var(--shadow-md);
}
.quote__portrait img {
    width: 100%;
    height: 100%;
    object-fit: cover;

    /* The source files are circular crops on a transparent ground; scaling by
       √2 pushes their corners outside this frame so the portrait reads square. */
    transform: scale(1.42);
}

.quote__portrait--initials {
    background: var(--purple-100);
    color: var(--purple-700);
    font-size: var(--fs-sm);
    font-weight: 700;
}

/* The zoom trigger is a button, so its own chrome is stripped back to the
   frame the portrait already had. */
.quote__portrait--zoom {
    position: relative;
    padding: 0;
    border: 0;
    cursor: zoom-in;
    transition: transform .25s var(--ease), box-shadow .25s var(--ease);
}
.quote__portrait--zoom:hover { transform: scale(1.06); box-shadow: var(--shadow-md); }

/* The magnifier only appears on hover or keyboard focus — at rest the row
   should read as an attribution, not a gallery. */
.quote__zoom-hint {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    background: rgba(20, 20, 43, .45);
    color: #fff;
    opacity: 0;
    transition: opacity .25s var(--ease);
}
.quote__portrait--zoom:hover .quote__zoom-hint,
.quote__portrait--zoom:focus-visible .quote__zoom-hint { opacity: 1; }

/* No <dialog> in this browser: the control cannot work, so it is not offered. */
.quote__portrait--zoom.is-inert { cursor: default; }
.quote__portrait--zoom.is-inert .quote__zoom-hint { display: none; }

/* ---- photo lightbox -----------------------------------------------------
   A real <dialog>, so focus, Escape and background inertness are the
   browser's job rather than ours.
   ------------------------------------------------------------------------ */
.lightbox {
    padding: 0;
    border: 0;
    background: transparent;

    /* Room for a gallery photograph. The portrait variant below pulls this
       in — a 300px source blown up to 880px would be a smear. */
    max-width: min(92vw, 880px);
    max-height: 92vh;
    overflow: visible;
}
.lightbox--portrait { max-width: min(92vw, 420px); }
.lightbox::backdrop { background: rgba(12, 10, 24, .78); }

/* ---- profit simulator ---------------------------------------------------
   Opened from "Hitung Simulasi Keuntungan" under the worked example. A real
   <dialog> for the same reason the lightbox is one: focus trapping, Escape and
   an inert background come free and correct.
   ------------------------------------------------------------------------ */
.sim {
    padding: 0;
    border: 0;
    background: transparent;
    max-width: min(94vw, 580px);

    /* No `max-height` and no `overflow` anywhere in this dialog — deliberately.
       A scrollbar inside a calculator hides the very line the calculator exists
       to show: the visitor changes a number at the top and the answer they were
       looking for has been pushed below the fold.

       Everything below is built to fit instead: five short fields on two rows,
       three result lines, no yearly figure, no per-field help text. It comes to
       roughly 470px, which clears a landscape phone. The short-viewport rule at
       the end tightens it further rather than letting it scroll. */
}

.sim::backdrop { background: rgba(12, 10, 24, .72); }

.sim__panel {
    padding: 26px;
    border-radius: var(--radius);
    background: var(--surface);
    box-shadow: 0 24px 60px rgba(17, 12, 46, .30);
}

.sim__head { position: relative; padding-right: 42px; margin-bottom: 18px; }
.sim__title { margin: 0; font-size: var(--fs-lg); line-height: 1.35; }

.sim__group-label {
    margin: 0 0 8px;
    font-family: var(--font-ui);
    font-weight: 600;
    font-size: var(--fs-xs);
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--muted);
}

.sim__close {
    position: absolute;
    top: -4px;
    right: -4px;
    display: grid;
    place-items: center;
    width: 36px;
    height: 36px;
    padding: 0;
    border: 1px solid var(--line);
    border-radius: 50%;
    background: #fff;
    color: var(--body);
    cursor: pointer;
    transition: background .2s var(--ease), color .2s var(--ease);
}
.sim__close:hover { background: var(--accent-tint); color: var(--logo-purple); }

.sim__fields { display: grid; gap: 12px; }

/* One column per kind of transaction, however many the controller defines.
   `auto-fit` rather than a fixed three: a fourth kind wraps to a second row on
   its own instead of squeezing four fields into three columns. */
.sim__fields--types {
    grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
    margin-bottom: 16px;
}
.sim__fields--rates {
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    margin-bottom: 18px;
}

.sim__label {
    display: block;
    margin-bottom: 5px;
    font-size: var(--fs-xs);
    color: var(--body);
    line-height: 1.35;

    /* Two lines of label max, so "Transfer virtual account" beside "Tarik
       tunai" cannot push one column taller than its neighbours. */
    min-height: 2.7em;
}

.sim__control { display: flex; align-items: stretch; }

.sim__input {
    width: 100%;
    min-width: 0;
    padding: 9px 12px;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    font-family: var(--font-ui);
    font-weight: 600;
    font-size: var(--fs-md);
    color: var(--ink);
    background: #fff;
}
.sim__input:focus-visible {
    outline: 2px solid var(--logo-purple);
    outline-offset: -1px;
}

/* Inside a `.sim__control` the input is joined to its unit, so the two share
   one outline instead of each carrying a rounded end in the middle. */
.sim__control .sim__input { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; border-left: 0; }

/* The unit is part of the control, not a word after it — "Rp 5000" reads as
   one value, "5000" followed by loose text does not. It leads on the rupiah
   field because that is where the currency goes in Indonesian. */
.sim__unit {
    display: grid;
    place-items: center;
    padding-inline: 11px;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm) 0 0 var(--radius-sm);
    background: var(--accent-tint);
    font-size: var(--fs-xs);
    color: var(--muted);
    white-space: nowrap;
}

.sim__results {
    margin: 0 0 12px;
    border: 1px solid var(--purple-200);
    border-radius: var(--radius-sm);
    overflow: hidden;
}

.sim__result {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 16px;
    padding: 10px 14px;
}
.sim__result + .sim__result { border-top: 1px dotted var(--purple-200); }
.sim__result dt { margin: 0; color: var(--body); font-size: var(--fs-sm); }
.sim__result dd {
    margin: 0;
    font-family: var(--font-ui);
    font-weight: 700;
    color: var(--accent-violet);

    /* Tabular figures: the totals change on every keystroke, and proportional
       digits make the whole line jitter sideways as they do. */
    font-variant-numeric: tabular-nums;
}

/* The month is the line the panel is built to land on — same treatment the
   total gets in the panel outside. */
.sim__result--month { background: var(--accent-tint); }
.sim__result--month dt { color: var(--ink); font-weight: 600; }
.sim__result--month dd { color: var(--accent-violet-deep); font-size: var(--fs-lg); }

.sim__note {
    margin: 0 0 16px;
    font-size: var(--fs-xs);
    color: var(--muted);
}

.sim__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.sim__actions .btn { width: 100%; }

@media (max-width: 480px) {
    .sim__panel { padding: 22px 18px; }
    .sim__label { min-height: 0; }
}

/* Landscape phones and short laptop windows. Everything tightens rather than
   the panel scrolling — see the note on `.sim`. */
@media (max-height: 620px) {
    .sim__panel { padding: 18px; }
    .sim__head { margin-bottom: 12px; }
    .sim__title { font-size: var(--fs-md); }
    .sim__label { min-height: 0; }
    .sim__fields { gap: 8px; }
    .sim__fields--types, .sim__fields--rates { margin-bottom: 10px; }
    .sim__input { padding: 7px 10px; }
    .sim__result { padding: 7px 12px; }
    .sim__note { margin-bottom: 12px; }
}

@media (prefers-reduced-motion: no-preference) {
    .sim[open] .sim__panel { animation: done-in .32s var(--ease) both; }
}

/* The trigger sits under the illustration note, clear of it. */
.estimate__actions { margin: 22px 0 0; }

/* ---- video player -------------------------------------------------------
   Built entirely by app.js, so there is no markup for these classes anywhere
   in the views — see initVideoModal().
   ------------------------------------------------------------------------ */
.player {
    padding: 0;
    border: 0;
    background: transparent;
    max-width: min(94vw, 940px);
    width: 100%;

    /* The close button sits outside the picture, so the dialog must not clip
       its own children. */
    overflow: visible;
}

/* A short is portrait: narrow enough that the 9:16 frame below fits the
   height of the screen rather than running off the bottom of it. */
.player--portrait { max-width: min(94vw, 420px); }

.player::backdrop { background: rgba(12, 10, 24, .84); }

.player__frame {
    position: relative;
    aspect-ratio: 16 / 9;
    border-radius: var(--radius);
    background: #000;
    box-shadow: 0 30px 70px rgba(0, 0, 0, .55);
    overflow: hidden;
}
.player--portrait .player__frame { aspect-ratio: 9 / 16; max-height: 84vh; margin-inline: auto; }

/* The iframe is injected, so it is matched by element rather than by class. */
.player__frame iframe {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    border: 0;
}

.player__close {
    position: absolute;
    top: -44px;
    right: 0;
    display: grid;
    place-items: center;
    width: 36px;
    height: 36px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: rgba(255, 255, 255, .14);
    color: #fff;
    font-size: 22px;
    line-height: 1;
    cursor: pointer;
    transition: background .2s var(--ease);
}
.player__close:hover { background: rgba(255, 255, 255, .28); }

/* On a short screen there is no room above the frame for the button, so it
   moves inside and gains a dark ground of its own to stay legible on the
   picture. */
@media (max-height: 700px) {
    .player__close { top: 8px; right: 8px; background: rgba(0, 0, 0, .55); }
    .player__close:hover { background: rgba(0, 0, 0, .75); }
}

@media (prefers-reduced-motion: no-preference) {
    .player[open] .player__frame { animation: done-in .32s var(--ease) both; }
}

/* ---- success dialog -----------------------------------------------------
   Shown once, after a registration is filed. Deliberately plain: the visitor
   has finished and the only thing left is to tell them so and get out of the
   way. */
.done {
    padding: 0;
    border: 0;
    background: transparent;
    max-width: min(92vw, 460px);
}

.done::backdrop { background: rgba(12, 10, 24, .70); }

.done__panel {
    padding: 34px 30px 30px;
    border-radius: var(--radius);
    background: var(--surface);
    box-shadow: 0 24px 60px rgba(17, 12, 46, .28);
    text-align: center;
}

/* The failure dialog. Same shell as the success one — only the mark, its
   colour and the panel's top edge differ, because the shape of the message is
   identical and only its meaning is not. */
.done--bad .done__panel { border-top: 4px solid #DC2626; }

.done__mark--bad { background: #FEF2F2; color: #B91C1C; }

/* The tick sits in its own disc so it reads as a result, not decoration. */
.done__mark {
    display: inline-grid;
    place-items: center;
    width: 62px;
    height: 62px;
    margin-bottom: 16px;
    border-radius: 50%;
    background: #ECFDF5;
    color: #059669;
}
.done__mark .icon { width: 30px; height: 30px; }

.done__title { margin: 0 0 10px; font-size: var(--fs-lg); }
.done__lead  { margin: 0 0 8px; color: var(--body); line-height: 1.6; }
.done__note  { margin: 0 0 22px; font-size: var(--fs-xs); color: var(--muted); line-height: 1.6; }
.done__panel .btn { width: 100%; }

@media (prefers-reduced-motion: no-preference) {
    .done[open] .done__panel { animation: done-in .3s var(--ease) both; }
}
@keyframes done-in {
    from { opacity: 0; transform: translateY(10px) scale(.98); }
    to   { opacity: 1; transform: none; }
}

.lightbox__frame {
    position: relative;
    padding: 14px;
    background: var(--surface);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-lg);
}

/* Default: an ordinary photograph, shown whole at its own proportions. */
.lightbox__media {
    overflow: hidden;
    border-radius: var(--radius);
    background: var(--purple-50);
}

.lightbox__img {
    display: block;
    width: 100%;
    height: auto;
}

/* The testimonial portraits only. Those files are circular crops on a
   transparent ground: left alone their corners would show the frame behind
   them and the photo would read as a disc again — the shape that was
   rejected. So the box clips and the image is scaled past its corners,
   exactly as on the card. */
.lightbox__media--square { aspect-ratio: 1 / 1; }
.lightbox__media--square .lightbox__img {
    height: 100%;
    object-fit: cover;
    transform: scale(1.42);
}

.lightbox__caption {
    margin: 12px 2px 2px;
    color: var(--body);
    font-size: var(--fs-sm);
    text-align: center;
}

.lightbox__close {
    position: absolute;
    top: -14px;
    right: -14px;
    width: 36px;
    height: 36px;
    display: grid;
    place-items: center;
    border: 0;
    border-radius: 50%;
    background: var(--ink);
    color: #fff;
    font-size: 22px;
    line-height: 1;
    box-shadow: var(--shadow-md);
}
.lightbox__close:hover { background: var(--purple-700); }

.quote__who { display: grid; gap: 3px; min-width: 0; }

.quote__name {
    font-size: var(--fs-sm);
    color: var(--ink);
    letter-spacing: -.01em;
}

.quote__meta { font-size: var(--fs-xs); color: var(--muted); }

/* The region drops to its own line rather than sitting behind a separator:
   business names run long, and a wrapped "·" reads as a typo. */
.quote__region { display: block; }

/* ---- bank logo sheet ----------------------------------------------------
   Capped rather than run edge to edge: the artwork is a fixed grid, and past
   about 1000px the logos start reading as billboards instead of a roster.
   ------------------------------------------------------------------------ */
.bank-sheet {
    max-width: 1000px;
    margin-inline: auto;
}

/* The sheet is one flat picture, so its panels cannot be staggered the way a
   real grid of cards would be — there is nothing to stagger. It arrives as a
   single object instead: rising a little further than the site's default
   reveal and settling out of a slight scale, which is what gives a wide, still
   image some sense of having landed rather than simply appeared.

   Exactly two classes, and written above `.reveal.is-visible` (line 3671).
   Three would outweigh it and pin the sheet at 97% for good. */
@media (scripting: enabled) {
    .bank-sheet.reveal { transform: translateY(26px) scale(.97); }
}

/* Slower than the site's other entrances: it is the widest single element on
   the page, and a wide object moving at the same rate as a small one looks
   hurried. */
.bank-sheet.reveal { transition-duration: 1.5s; }
.bank-sheet img {
    width: 100%;
    height: auto;

    /* `drop-shadow`, and the choice is not cosmetic: the file is transparent
       at its corners and in every gap between the panels, so this traces each
       rounded panel on its own. A `box-shadow` would draw one rectangle round
       the lot of them — including the empty space between — and read as a
       frame that is not there.

       Lighter than the shared token because the panels are already drawn with
       soft shadows of their own; this only has to separate them from the page
       behind, not invent depth they already have. */
    filter: drop-shadow(0 6px 14px rgba(20, 20, 43, .15));

    /* Idle drift on the image, never on `.bank-sheet` — the wrapper's transform
       belongs to the reveal, and an infinite animation on the same property
       would overrule the transition and strand it mid-entrance.

       Twenty seconds and four pixels. Eleven and six still read as movement —
       on an object this wide the eye tracks the whole edge at once, so the same
       speed that is invisible on a 150px icon is plainly visible here. Big
       things have to move slower than feels right on paper. */
    animation: bank-sheet-float 20s ease-in-out infinite;
}

@keyframes bank-sheet-float {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(-4px); }
}

/* ---- tabs: the two explanatory blocks, one at a time --------------------
   A segmented control rather than underlined tabs: the two labels are long
   sentences, and a filled pill carries a label of that length far better
   than a 2px underline does.

   The strip is `hidden` in the markup and unhidden by the script, so a page
   without JavaScript shows both panels stacked and headed instead of a row
   of buttons that do nothing.
   ------------------------------------------------------------------------ */
.tabs { max-width: 940px; margin-inline: auto; }

/* With an illustration beside it the component gives up its own centring and
   simply fills the column it is given. */
.tabs-layout {
    display: grid;
    grid-template-columns: minmax(0, .8fr) minmax(0, 1.55fr);
    align-items: center;
    gap: clamp(24px, 4vw, 56px);
}
.tabs-layout .tabs { max-width: none; margin-inline: 0; }

.tabs-layout__media { display: grid; place-items: center; }
.tabs-layout__media img {
    width: 100%;
    max-width: 400px;
    filter: var(--art-shadow);
    animation: qa-art-float 7.5s ease-in-out infinite;
}

@keyframes qa-art-float {
    0%, 100% { transform: translateY(0)   rotate(0deg); }
    50%      { transform: translateY(-9px) rotate(-.6deg); }
}

/* Fading out is slower than fading in. Something arriving should feel prompt;
   something leaving should not appear to have been snatched away. The `.reveal`
   pair below sets the quicker curve for the way in, and this is what is left
   once `is-visible` is taken off again. */
   Written as `.reveal[data-fade]`, not `[data-fade]` on its own. A bare
   attribute selector weighs the same as `.reveal`, and `.reveal` sets its own
   `transition` several hundred lines below this — equal weight, written later,
   so it would win and this would never apply at all. */
.reveal[data-fade] { transition: opacity 1.5s var(--ease), transform 1.5s var(--ease); }
.reveal[data-fade].is-visible { transition: opacity 1.05s var(--ease-entrance), transform 1.05s var(--ease-entrance); }

/* Enters from its own side, like the intro band and the estimate artwork.
   Two classes exactly — see the note on `.estimate__row--total`. */
@media (scripting: enabled) {
    .tabs-layout__media.reveal { transform: translateX(-30px) translateY(8px); }
}

/* `.tabs` wears `.reveal` only to find out when it is on screen; it must not
   fade like one. This cancels the hidden start state, so the class arrives
   without the element ever having been invisible. */
@media (scripting: enabled) {
    .tabs.reveal { opacity: 1; transform: none; }
}

.tabs__list {
    display: flex;
    gap: 6px;
    padding: 6px;
    margin-bottom: clamp(26px, 3vw, 38px);
    background: var(--purple-50);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-md);
}
.tabs__list[hidden] { display: none; }

/* The strip arrives ahead of the list it controls. */
.tabs.is-visible .tabs__list { animation: qa-item-in .7s var(--ease-entrance) both; }

.tabs__tab {
    flex: 1 1 0;
    padding: 14px 18px;
    border: 0;
    border-radius: 12px;
    background: transparent;
    color: var(--ink-soft);
    font-size: var(--fs-sm);
    font-weight: 600;
    line-height: 1.4;
    text-align: center;
    transition: background .25s var(--ease), color .25s var(--ease), box-shadow .25s var(--ease);
}
.tabs__tab:hover { color: var(--purple-700); }

.tabs__tab[aria-selected="true"] {
    background: var(--surface);
    color: var(--ink);

    /* Heavier than the strip it sits in, so the selected tab reads as lifted
       out of the groove rather than merely paler than its neighbour. */
    box-shadow: 0 3px 10px rgba(20, 20, 43, .12);
}

/* Panels are only ever hidden by the script, never by CSS — see above. */
.tabs__panel[hidden] { display: none; }
.tabs__panel:focus-visible { outline-offset: 6px; }

/* Once the tabs are live the tab label is the panel's visible heading, so
   the in-panel copy of it is left for assistive tech only. Without JS the
   class is never applied and both headings show. */
.tabs.is-ready .tabs__panel-title {
    position: absolute;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0 0 0 0);
    white-space: nowrap;
}

/* Stacked fallback: without JS the two panels need telling apart. */
.tabs__panel + .tabs__panel { margin-top: clamp(34px, 4vw, 54px); }
.tabs.is-ready .tabs__panel + .tabs__panel { margin-top: 0; }

.tabs__panel-title { font-size: var(--fs-2xl); }

.qa-block__lead {
    margin-bottom: 22px;
    font-size: var(--fs-md);
    color: var(--ink-soft);
}

/* The site resets list markers, so the numbers are counters. That is also
   what lets each one sit in a tinted badge without extra markup. */
.qa-list { counter-reset: qa; display: grid; gap: 0; }

/* A dotted hairline between entries.
 *
 * The items vary from four words to three full lines, so with nothing between
 * them the eye cannot tell where one ends and the next begins — a long item
 * followed by a short one reads as a single paragraph with a stray number in
 * it. The rule is what turns the block into a list you can scan.
 *
 * Dotted and in the palette's lightest purple rather than a solid grey: this
 * is a separator, not a border, and it should be findable when looked for and
 * invisible when not.
 *
 * On the separator, not on every item — `li + li` means the first entry has no
 * line above it and the last none below, so the list is bounded by its own
 * content rather than by two floating rules. The padding is split either side
 * of the line so the text sits centred in its band; the `gap` above is
 * therefore 0, or the two would add up and the spacing would double. */
.qa-list li + li {
    margin-top: 14px;
    padding-top: 14px;
}

/* Drawn as a pseudo-element, not as `border-top: dotted`.
 *
 * A border cannot be animated open and cannot take a shadow of its own — a
 * `box-shadow` on the <li> would trace the whole row, not the line. As an
 * element the rule can be scaled from zero, and its shadow follows the dashes.
 *
 * `::after` specifically: `::before` is already the numbered badge.
 *
 * The dashes are a repeating gradient rather than a dotted border style
 * because a border's dot spacing is the browser's to choose and cannot be
 * scaled — this way the dash and the gap are both stated, and identical in
 * every browser. */
.qa-list li { position: relative; }

.qa-list li + li::after {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 2px;

    background-image: linear-gradient(90deg, var(--purple-200) 0 6px, transparent 6px 12px);
    background-size: 12px 2px;
    background-repeat: repeat-x;

    /* No shadow. It was tried and taken off: on a 2px dashed rule even a 1px
       offset reads as a smudge under the dashes rather than as depth, and the
       list looked less crisp with it than without. The line's job is to
       separate, and a flat line does that better.

       Grows from the left, which is where the text it separates begins. */
    transform-origin: left center;
    pointer-events: none;
}

/* ---- numbered items arriving in turn ------------------------------------
   An animation rather than the `.reveal` transition, and deliberately so: the
   panels are swapped by toggling `hidden`, and going from `display: none` back
   to displayed restarts a CSS animation from the beginning. So switching tabs
   replays the list for the panel you just opened, with no JavaScript involved
   and no state to keep.

   `.tabs.is-visible` gates the whole thing on the block actually being on
   screen. Without it the list would count itself in while the visitor was
   still three sections above, and arrive already finished.

   The delay is a custom property rather than eight repeated `animation-delay`
   declarations, because the divider below has to arrive on the same beat as
   the item it belongs to. Custom properties inherit into pseudo-elements;
   `animation-delay` does not. Setting `--qa-delay` once per row therefore
   drives both the text and its rule from a single number.

   Eight covers the longer of the two lists; anything past that arrives with
   the last one, which is a fair degradation.
   ------------------------------------------------------------------------ */
.qa-list li:nth-child(1) { --qa-delay: .10s; }
.qa-list li:nth-child(2) { --qa-delay: .20s; }
.qa-list li:nth-child(3) { --qa-delay: .30s; }
.qa-list li:nth-child(4) { --qa-delay: .40s; }
.qa-list li:nth-child(5) { --qa-delay: .49s; }
.qa-list li:nth-child(6) { --qa-delay: .59s; }
.qa-list li:nth-child(7) { --qa-delay: .69s; }
.qa-list li:nth-child(n+8) { --qa-delay: .79s; }

.tabs.is-visible .tabs__panel:not([hidden]) .qa-list li {
    animation: qa-item-in .78s var(--ease-entrance) var(--qa-delay, 0s) both;
}

/* The rule draws itself out from the left, a beat behind the text above it.
   Behind, not with: the line belongs to the item that follows it, so it should
   look like it is being ruled to make room for what is arriving, rather than
   racing it. */
.tabs.is-visible .tabs__panel:not([hidden]) .qa-list li + li::after {
    animation: qa-rule-in .7s var(--ease-entrance) calc(var(--qa-delay, 0s) + .1s) both;
}

@keyframes qa-item-in {
    from { opacity: 0; transform: translateY(12px); }
    to   { opacity: 1; transform: none; }
}

@keyframes qa-rule-in {
    from { opacity: 0; transform: scaleX(0); }
    to   { opacity: 1; transform: scaleX(1); }
}

.qa-list li {
    counter-increment: qa;
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: start;
    gap: 14px;
    font-size: var(--fs-sm);
    line-height: 1.75;
    color: var(--body);
}

.qa-list li::before {
    content: counter(qa);
    display: grid;
    place-items: center;
    width: 26px;
    height: 26px;

    /* Nudged down so the badge lines up with the first line of text rather
       than with the top of its box. */
    margin-top: 2px;

    border-radius: 8px;
    background: var(--purple-100);
    color: var(--purple-700);
    font-size: var(--fs-2xs);
    font-weight: 700;
    font-variant-numeric: tabular-nums;

    /* Lifts the badge off the page so the column of numbers reads as a rail
       running down the list rather than as tinted squares behind the text. */
    box-shadow: 0 2px 5px rgba(20, 20, 43, .13);
}

/* ---- flow: the steps to becoming an agent -------------------------------
   The dashed rail is drawn on the list itself, not on each step, because it
   has to cross the grid gaps — a pseudo-element on a step can only reach the
   step's own edges. The numbered badges are opaque and sit above it, so the
   rail reads as running between them rather than under them.
   ------------------------------------------------------------------------ */
.flow {
    position: relative;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr));
    gap: clamp(26px, 3vw, 36px);
}

.flow::before {
    content: "";
    position: absolute;

    /* The badge is the first thing in every step and is 38px tall, so its
       centre is a fixed distance from the top of the row whatever width the
       illustrations end up at. */
    top: 19px;
    left: 12%;
    right: 12%;
    border-top: 2px dashed var(--purple-200);
}

.flow__step {
    position: relative;
    display: grid;
    justify-items: center;
    gap: 12px;
    text-align: center;
}

.flow__media img { transition: transform .35s var(--ease); }
.flow__step:hover .flow__media img { transform: translateY(-6px); }

.flow__num {
    display: grid;
    place-items: center;
    width: 38px;
    height: 38px;
    border-radius: 50%;
    transition: transform .3s var(--ease), box-shadow .3s var(--ease);

    /* Opaque: this is what masks the rail behind it. */
    background: var(--purple-600);
    color: #fff;
    font-size: var(--fs-sm);
    font-weight: 700;
    box-shadow: 0 6px 14px rgba(124, 58, 237, .28);
}
.flow__step:hover .flow__num {
    transform: scale(1.08);
    box-shadow: 0 8px 18px rgba(124, 58, 237, .38);
}

/* A fixed box the artwork is fitted into, rather than letting each image set
   its own height. The four are close in shape but not identical, and left to
   themselves they would leave the four step titles sitting on four different
   lines. `contain` means nothing is cropped to achieve it. */
/* Square, because the four source files are square — see the note on
   FLOW_STEPS. With identical canvases carrying identically-scaled subjects on a
   shared floor line, the box has nothing left to correct: every image fills it
   exactly, so all four render at the same width and the same height and their
   plinths land on one line.
 *
 * This box used to be 4/5 and used to be doing the evening-out itself, badly.
 * It cannot: `contain` fits the canvas, and the canvas is not the picture. */
.flow__media {
    display: grid;
    width: clamp(180px, 20vw, 240px);
    aspect-ratio: 1 / 1;
}
.flow__media img {
    width: 100%;
    height: 100%;

    /* `contain` rather than `cover` even now that the shapes match: it costs
       nothing when they do, and it degrades to letterboxing rather than
       cropping somebody's head off if a file of the wrong shape is dropped in
       before the script is run. */
    object-fit: contain;

    filter: var(--art-shadow-sm);
}

.flow__title { margin: 0; font-size: var(--fs-lg); }

.flow__text {
    margin: 0;
    max-width: 30ch;
    font-size: var(--fs-sm);
    line-height: 1.7;
    color: var(--body);
}

/* --------------------------------------------------------------------------
   9a. Reason cards — "Kenapa Semakin Banyak Orang Memilih…"
   -------------------------------------------------------------------------- */
/* The supplied section graphic, used instead of the card grid.
 *
 * Nothing but a centred image — no border, no card, no shadow. The picture
 * already carries its own boxes and its own type, and framing it again would
 * put a card inside a card.
 *
 * ⚠ The words in this image are pixels. It cannot reflow, so on a narrow phone
 * the whole 1536px-wide graphic is squeezed to the screen width and its text
 * shrinks with it. The card grid below does reflow; if the text stops being
 * legible on a phone, that is the reason to go back to it. */
.reason-art { margin: 0; }
.reason-art img {
    display: block;
    width: 100%;
    max-width: 1100px;
    height: auto;
    margin-inline: auto;
}

.reason-grid {
    display: grid;

    /* Four across on a wide screen, folding to two and then one. `1fr` rather
       than `auto-fit` with a floor: these are a fixed set of four and should
       stay a tidy 4 / 2 / 1, not reflow into a ragged three. */
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: clamp(14px, 1.8vw, 22px);
    margin: 0;
    padding: 0;
    list-style: none;
}

.reason-card {
    display: flex;
    flex-direction: column;

    /* The words sit at the foot of the box, with the artwork above them. That
       is the whole look: it gives four short statements the room a long one
       would have taken, so the row stays even however unequal the sentences
       are. */
    justify-content: flex-end;

    min-height: clamp(260px, 27vw, 330px);
    padding: clamp(20px, 2.2vw, 28px);

    border: 1px solid var(--line-firm);
    border-radius: 18px;
    background: var(--surface);
}

/* ---- the mark at the head of each card ----------------------------------
   The card is `justify-content: flex-end`, so everything packs to the foot and
   whatever height is left over opens up above the artwork. One shared bottom
   margin sets the gap between the mark and the title, whichever kind of mark
   the card ended up with.

   The isometric renders are 380–630px squares of very different proportions,
   so the box is fixed and the picture fitted inside it — `contain`, never
   `cover`, which would crop the plinth off the bottom of the taller ones.

   Sized at roughly twice what it first had. These are detailed 3D renders: at
   90px the plinth, the figures and the map squares collapse into one violet
   smudge, and the picture stops carrying any meaning at all.
   ------------------------------------------------------------------------ */
.reason-card__art,
.reason-card__icon {
    display: block;
    margin-bottom: clamp(16px, 1.8vw, 24px);
}

.reason-card__art {
    width: 100%;
    height: clamp(130px, 13vw, 168px);
}

.reason-card__art img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: contain;
    object-position: center;

    /* Grounds the cut-out. The renders carry their own soft shadow, so this is
       lighter than the artwork token used elsewhere — enough to sit on the
       card, not enough to double up. */
    filter: drop-shadow(0 8px 14px rgba(20, 20, 43, .16));
}

/* The flat fallback centres with the renders, so a card that lost its file
   does not also shift its whole composition to the left. */
.reason-card__icon { align-self: center; color: var(--logo-purple); }
.reason-card__icon .icon { width: 44px; height: 44px; }

.reason-card__title {
    margin: 0 0 10px;

    /* Deliberately not `var(--font-display)`: these read as UI labels rather
       than headings, and Montserrat at this size beside the body face below
       makes them look like section titles that lost their section. */
    font-family: var(--font-ui);
    font-weight: 700;
    font-size: var(--fs-lg);
    line-height: 1.25;
    letter-spacing: -.01em;
    color: var(--ink);
    text-wrap: balance;
}

.reason-card__text {
    margin: 0;
    font-size: var(--fs-sm);
    line-height: 1.65;
    color: var(--body);
}

/* Two up, then one. The 300px floor is what stops a card's title breaking
   after every second word on a narrow phone. */
@media (max-width: 900px) {
    .reason-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 520px) {
    .reason-grid { grid-template-columns: minmax(0, 1fr); }
    .reason-card { min-height: 0; }
}

/* --------------------------------------------------------------------------
   9. Statistics
   -------------------------------------------------------------------------- */
.stat-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
    gap: 18px;
}

.stat-card {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    padding: 26px 18px;
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm);
    text-align: center;
    transition: transform .28s var(--ease), box-shadow .28s var(--ease);
}
.stat-card:hover { transform: translateY(-6px); box-shadow: var(--lift-1); }

.stat-card__icon {
    display: grid;
    place-items: center;
    width: 44px;
    height: 44px;
    border-radius: 50%;
    background: var(--purple-100);
    color: var(--purple-600);
    margin-bottom: 4px;
}
.stat-card__icon[data-tone="orange"] { background: var(--orange-100); color: var(--orange-500); }
.stat-card__icon[data-tone="blue"]   { background: var(--blue-100);   color: var(--blue-500); }
.stat-card__icon[data-tone="pink"]   { background: #FCE7F3;           color: var(--pink-500); }

/* Supplied artwork replaces the tinted disc. No plate behind it: these are
   transparent illustrations and a coloured circle would crop them. */
.stat-card__art {
    display: grid;
    place-items: center;
    width: clamp(72px, 9vw, 96px);
    aspect-ratio: 1 / 1;
    margin-bottom: 4px;
}
.stat-card__art img {
    width: 100%;
    height: 100%;
    object-fit: contain;

    /* The small shadow: these are under 100px, and the full-size one reads as
       a grey smear rather than a shadow at that scale. */
    filter: var(--art-shadow-sm);
    transition: transform .3s var(--ease);
}
.stat-card:hover .stat-card__art img { transform: translateY(-4px) scale(1.04); }

.stat-card__value {
    font-size: var(--fs-3xl);
    font-weight: 700;

    /* Black rather than the brand purple. Each card already carries a coloured
       illustration, and a purple figure beside it made two things competing to
       be looked at first. The number is the point of the card, and at this
       weight and size it needs no colour to say so. */
    color: var(--ink);
    line-height: 1.1;
}
.stat-card__label { font-size: var(--fs-sm); color: var(--body); }

/* --------------------------------------------------------------------------
   10. Gallery carousel
   -------------------------------------------------------------------------- */
.carousel { position: relative; }

.carousel__viewport {
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x mandatory;
    scroll-behavior: smooth;
    scrollbar-width: none;
    -ms-overflow-style: none;

    /* This padding is what the cards' shadows live in, and it is the reason
       they are not sliced off.

       `overflow-y: hidden` is not optional here — it is what stops the
       horizontal scroller growing a vertical one — so anything a card paints
       outside this box is cut, shadow included. The old 6px/12px was sized for
       a 4px-deep shadow, and the deeper one below would have ended in a hard
       horizontal line across every photo.

       A shadow reaches `offset + blur / 2` past the edge, not offset + blur:
       the blur is centred on the shadow's own boundary, so only half of it
       falls outside. On hover that is 16 + 30/2, plus the 5px the card lifts —
       36px. Forty leaves a margin for it.

       Recalculate this whenever the shadow or the hover lift grows. */
    padding: 14px 4px 40px;
}
.carousel__viewport::-webkit-scrollbar { display: none; }

.carousel__track { display: flex; gap: 18px; }

/* Exactly five thumbnails per page, as in the reference design. The gap is
   subtracted so the fifth lands flush with the right edge instead of being
   clipped. */
.carousel__item {
    flex: 0 0 calc((100% - 4 * 18px) / 5);
    scroll-snap-align: start;
}

.gallery-card {
    display: block;
    position: relative;

    /* The card is a <figure>, which browsers give `margin: 1em 40px` of its
       own. Left alone it pushes 40px of white between neighbouring photos on
       top of the track's gap, and drops each one out of line vertically. */
    margin: 0;

    border-radius: var(--radius-sm);
    overflow: hidden;
    aspect-ratio: 4 / 3;

    /* Two layers, like the video cards: a soft pool for depth and a tight dark
       one so the rounded corners keep a defined edge. Kept fairly shallow on
       purpose — the strip's clipping viewport above sets a hard ceiling on how
       far any of this may reach. */
    box-shadow:
        0 8px 18px rgba(20, 20, 43, .18),
        0 2px 5px rgba(20, 20, 43, .11);

    transition: transform .28s var(--ease), box-shadow .28s var(--ease);
}

.gallery-card:hover,
.gallery-card:focus-visible {
    transform: translateY(-5px) scale(1.015);
    box-shadow:
        0 16px 30px rgba(20, 20, 43, .26),
        0 3px 7px rgba(20, 20, 43, .13);
}
.gallery-card img { width: 100%; height: 100%; object-fit: cover; }

/* The card is a <button> now, so its own chrome is stripped back to the frame
   the photograph already had. */
.gallery-card--zoom {
    width: 100%;
    padding: 0;
    border: 0;
    cursor: zoom-in;
}

/* Magnifier on hover or keyboard focus only — at rest the strip should read
   as photographs, not as a row of controls. */
.gallery-card__zoom {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    background: rgba(20, 20, 43, .42);
    color: #fff;
    opacity: 0;
    transition: opacity .25s var(--ease);
}
.gallery-card--zoom:hover .gallery-card__zoom,
.gallery-card--zoom:focus-visible .gallery-card__zoom { opacity: 1; }

.gallery-card__caption {
    position: absolute;
    inset: auto 0 0 0;
    padding: 26px 12px 10px;
    background: linear-gradient(transparent, rgba(20, 20, 43, .8));
    color: #fff;
    font-size: var(--fs-2xs);
    font-weight: 500;
    opacity: 0;
    transition: opacity .25s var(--ease);
}
.gallery-card:hover .gallery-card__caption,
.gallery-card:focus-visible .gallery-card__caption { opacity: 1; }

.carousel__btn {
    position: absolute;
    top: 50%;
    z-index: 3;
    display: grid;
    place-items: center;
    width: 38px;
    height: 38px;
    border: 1px solid var(--line);
    border-radius: 50%;
    background: #fff;
    color: var(--ink-soft);
    box-shadow: var(--shadow-md);
    transform: translateY(-50%);
    transition: background .2s var(--ease), color .2s var(--ease), opacity .2s var(--ease);
}
.carousel__btn:hover { background: var(--purple-600); color: #fff; border-color: var(--purple-600); }
.carousel__btn:disabled { opacity: .35; cursor: default; }
.carousel__btn:disabled:hover { background: #fff; color: var(--ink-soft); border-color: var(--line); }
.carousel__btn--prev { left: -14px; }
.carousel__btn--next { right: -14px; }
.carousel__btn .icon { width: 18px; height: 18px; }

/* No top margin, because the viewport above now carries 40px of its own bottom
   padding to hold the cards' shadows. Left at 20px the dots would drift a long
   way clear of the strip they belong to. */
.carousel__dots { display: flex; justify-content: center; gap: 8px; margin-top: 0; }
.carousel__dot {
    width: 8px; height: 8px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: var(--purple-200);
    transition: background .2s var(--ease), width .2s var(--ease);
}
.carousel__dot.is-active { background: var(--purple-600); width: 22px; border-radius: 4px; }

/* --------------------------------------------------------------------------
   11. CTA banner
   -------------------------------------------------------------------------- */
.cta {
    position: relative;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: clamp(20px, 4vw, 40px);
    padding: clamp(28px, 4vw, 40px) clamp(24px, 4vw, 44px);
    border-radius: var(--radius-lg);

    /* The wordmark's own purple-to-blue. The banner is the loudest thing on
       the page, so it is the one that most needs to match the logo rather
       than sit a shade away from it. */
    background: var(--grad-logo);
    box-shadow: var(--shadow-cta);
    overflow: hidden;
    color: #fff;
}
.cta::after {
    content: "";
    position: absolute;
    right: -60px; top: -80px;
    width: 300px; height: 300px;
    border-radius: 50%;
    background: rgba(255, 255, 255, .09);
    pointer-events: none;
}

.cta__icon {
    display: grid;
    place-items: center;
    width: 58px; height: 58px;
    flex-shrink: 0;
    border-radius: 50%;
    background: rgba(255, 255, 255, .18);
    color: #fff;
}
.cta__body { flex: 1 1 320px; position: relative; z-index: 1; }
.cta__title { margin: 0 0 6px; color: #fff; font-size: var(--fs-xl); }
.cta__text { margin: 0; color: rgba(255, 255, 255, .86); font-size: var(--fs-md); }
.cta__actions { position: relative; z-index: 1; display: flex; flex-wrap: wrap; gap: 12px; }

/* --------------------------------------------------------------------------
   12. Page header (inner pages)
   -------------------------------------------------------------------------- */
/* Wash shared with .hero — see the note there. */
.page-head {
    position: relative;
    padding: clamp(44px, 6vw, 72px) 0;
    border-bottom: 1px solid var(--line);
    text-align: center;
    overflow: hidden;
}
.page-head__inner { position: relative; z-index: 1; }
.page-head__title { margin-bottom: 10px; font-size: var(--fs-3xl); }
.page-head__lead { max-width: 60ch; margin-inline: auto; color: var(--body); }

/* ---- inner-page banner entrance ----------------------------------------
   Every page that is not the landing page opens with this block, so one rule
   here gives /gabung, /sertifikat, /masuk and every /informasi page the same
   arrival the hero has.

   Animations, not `.reveal`: this is always above the fold, so there is
   nothing to wait for and no observer to involve. Crumbs, then title, then
   lead — reading order and arrival order the same, as in the hero.

   `both` fill on all three. Without it they would flash at full opacity for
   one frame before their delay starts. */
.breadcrumb        { animation: hero-copy-in .95s var(--ease-entrance) .08s both; }
.page-head__title  { animation: hero-copy-in 1.1s var(--ease-entrance) .22s both; }
.page-head__lead   { animation: hero-copy-in 1.1s var(--ease-entrance) .42s both; }

.breadcrumb {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 8px;
    margin-bottom: 14px;
    font-size: var(--fs-xs);
    color: var(--muted);
}
.breadcrumb li + li::before { content: "/"; margin-right: 8px; color: var(--purple-200); }

/* --------------------------------------------------------------------------
   13. Content cards (activity / news listings)
   -------------------------------------------------------------------------- */
.post-card {
    display: flex;
    flex-direction: column;
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    overflow: hidden;
    box-shadow: var(--shadow-sm);
    transition: transform .28s var(--ease), box-shadow .28s var(--ease), border-color .28s var(--ease);
}
.post-card:hover {
    transform: translateY(-5px);
    border-color: var(--purple-200);
    box-shadow: var(--shadow-lg);
}

.post-card__media {
    aspect-ratio: 16 / 10;
    overflow: hidden;
    background: var(--purple-50);

    /* Sits flush inside a card that already has a shadow of its own, so this
       is only a seam — enough to separate the picture from the text below it
       without putting a second card edge inside the card. */
    box-shadow: 0 6px 14px rgba(20, 20, 43, .10);
}
.post-card__media img { width: 100%; height: 100%; object-fit: cover; transition: transform .4s var(--ease); }
.post-card:hover .post-card__media img { transform: scale(1.05); }

.post-card__body { display: flex; flex-direction: column; gap: 10px; padding: 20px; flex: 1; }
.post-card__title { margin: 0; font-size: var(--fs-lg); }
.post-card__title a { color: var(--ink); }
.post-card__title a:hover { color: var(--purple-700); }
.post-card__text { font-size: var(--fs-sm); color: var(--body); }
.post-card__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 14px;
    margin-top: auto;
    padding-top: 6px;
    font-size: var(--fs-xs);
    color: var(--muted);
}
.post-card__meta span { display: inline-flex; align-items: center; gap: 6px; }
.post-card__meta .icon { width: 15px; height: 15px; }

.badge {
    display: inline-flex;
    align-items: center;
    padding: 4px 12px;
    border-radius: 999px;
    background: var(--purple-100);
    color: var(--purple-700);
    font-size: var(--fs-2xs);
    font-weight: 600;
    letter-spacing: .01em;
}
.badge--soft { background: var(--surface-soft); color: var(--muted); }

/* --------------------------------------------------------------------------
   14. Article body
   -------------------------------------------------------------------------- */
.article { max-width: 760px; margin-inline: auto; }
.article__cover {
    border-radius: var(--radius);
    overflow: hidden;
    margin-bottom: 28px;
    box-shadow: var(--shadow-md);
    aspect-ratio: 16 / 9;
}
.article__cover img { width: 100%; height: 100%; object-fit: cover; }

.article__meta {
    display: flex;
    flex-wrap: wrap;
    gap: 16px;
    margin-bottom: 22px;
    font-size: var(--fs-xs);
    color: var(--muted);
}
.article__meta span { display: inline-flex; align-items: center; gap: 6px; }

.prose { color: var(--body); font-size: var(--fs-md); }
.prose h2 { margin-top: 1.8em; font-size: var(--fs-xl); }
.prose h3 { margin-top: 1.5em; font-size: var(--fs-lg); }
.prose ul { margin: 0 0 1.1rem; padding-left: 1.2rem; list-style: disc; }
.prose ol { margin: 0 0 1.1rem; padding-left: 1.3rem; list-style: decimal; }
.prose li { margin-bottom: .4rem; }
.prose blockquote {
    margin: 1.4rem 0;
    padding: 4px 0 4px 20px;
    border-left: 3px solid var(--purple-400);
    color: var(--ink-soft);
    font-style: italic;
}

/* --------------------------------------------------------------------------
   15. Forms
   -------------------------------------------------------------------------- */
.form-grid { display: grid; gap: 18px; }
.form-grid--2 { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }

.field { display: flex; flex-direction: column; gap: 7px; }
.field__label { font-size: var(--fs-sm); font-weight: 600; color: var(--ink-soft); }
.field__label .req { color: var(--pink-500); }
.field__hint { font-size: var(--fs-2xs); color: var(--muted); }

/* The upload constraint, sat under the descriptive hint. Held slightly firmer
   than the hint above it: this one is a rule the form will enforce, not advice
   about what to photograph, and it should not read as more of the same. */
.field__hint--limit { font-weight: 600; color: var(--ink-soft); }

/* ---- an input with buttons beside it ------------------------------------
   Used by the coordinate field: a read-only box, the button that fills it,
   and a link out to Google Maps.

   Wraps rather than scrolls, because the two buttons plus a coordinate pair
   do not fit a phone in one line and a squeezed input is worse than a second
   row. `flex: 1 1 220px` is what makes the input take the whole width the
   moment they wrap, instead of sitting stranded at its content size.
   ------------------------------------------------------------------------ */
.field__group { display: flex; flex-wrap: wrap; gap: 8px; align-items: stretch; }
.field__group .input { flex: 1 1 220px; width: auto; }

.field__group-btn {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    gap: 7px;
    padding-inline: 14px;
    font-size: var(--fs-xs);
    white-space: nowrap;
}
.field__group-btn[hidden] { display: none; }

/* One button or the other, never both.
 *
 * On a phone, GPS: the device is standing in the shop it is being asked to pin,
 * so it can simply say where that is.
 *
 * On a desktop, Google Maps: a browser's "current location" there is whatever
 * its network implies, which can be a different city. A shop pinned to an ISP's
 * exchange is worse than a shop with no pin at all, because it looks like data.
 *
 * `!important` on the GPS rule because app.js clears its `hidden` attribute
 * once it knows the API exists; without it, a desktop browser that happens to
 * expose geolocation would show the button anyway. */
@media (min-width: 901px) {
    .field__group-btn--gps { display: none !important; }
}
@media (max-width: 900px) {
    .field__group-btn--map { display: none; }
}

/* Read-only, and it has to look it. Left as an ordinary white box it invites
   people to click into it and type, and nothing happens — which reads as the
   form being broken rather than as the field being filled for them. */
.input[readonly] {
    background: var(--purple-50);
    color: var(--ink-soft);
    cursor: default;
}
.input[readonly]:focus-visible { border-color: var(--line); box-shadow: none; }

.input, .textarea, .select {
    width: 100%;
    padding: 12px 14px;
    border: 1.5px solid var(--line);
    border-radius: var(--radius-sm);
    background: #fff;
    color: var(--ink);
    font: inherit;
    font-size: var(--fs-sm);
    transition: border-color .2s var(--ease), box-shadow .2s var(--ease);
}
/* A hairline warms on hover, before the field is even clicked — it tells you
   the row is a control without waiting for focus. */
.input:hover, .textarea:hover, .select:hover { border-color: var(--purple-200); }

.input:focus, .textarea:focus, .select:focus {
    outline: none;
    border-color: var(--purple-400);
    box-shadow: 0 0 0 4px rgba(139, 92, 246, .14);
}

/* A filled-in field is lifted slightly out of the page, so a long form shows
   its own progress as you go down it. `:not(:placeholder-shown)` is what
   distinguishes answered from merely present. */
.input:not(:placeholder-shown):not(:focus) {
    border-color: var(--purple-100);
    background: #FDFDFF;
}

/* Placeholders carry worked examples, so they have to be readable rather than
   merely present. --body clears 4.5:1 on white while staying plainly lighter
   than --ink, so an example still does not read as something already typed;
   --muted looks right but only reaches 3.3:1. The explicit opacity is for
   Firefox, which dims placeholders by default and would undo the choice. */
.input::placeholder, .textarea::placeholder {
    color: var(--body);
    opacity: 1;
}
.textarea { min-height: 150px; resize: vertical; }

/* File inputs are drawn by the browser and ignore most styling; padding is
   trimmed so the native button does not sit cramped against the border. */
.input--file { padding: 9px 12px; cursor: pointer; }
.input--file::file-selector-button {
    margin-right: 12px;
    padding: 7px 14px;
    border: 0;
    border-radius: var(--radius-sm);
    background: var(--purple-100);
    color: var(--purple-700);
    font: inherit;
    font-size: var(--fs-xs);
    font-weight: 600;
    cursor: pointer;
}
.input--file::file-selector-button:hover { background: var(--purple-200); }

/* ---- long forms, laid out across the screen -----------------------------
   Each group is a cell in one grid. `auto-fit` with a minimum means the
   browser decides how many fit — two blocks side by side on a laptop, one
   per row on a phone — rather than a breakpoint guessing at it.

   The minimum is 420px because a pair of fields below that width starts
   wrapping labels, at which point two columns are worse than one.
   ------------------------------------------------------------------------ */
.form-layout {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
    gap: clamp(18px, 2.5vw, 26px);
    align-items: start;
}

/* Blocks that hold four fields or a long address need the full row. */
.form-block--wide { grid-column: 1 / -1; }

.form-block__title {
    margin-bottom: 20px;
    padding-bottom: 14px;
    border-bottom: 1px solid var(--line);
    font-size: var(--fs-lg);
}

/* Four across on a wide screen, folding to two and then one. Same auto-fit
   reasoning: the fields decide, not a media query. */
.form-grid--4 {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr));
    gap: 18px;
}

.textarea--short { min-height: 84px; }

.form-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 16px 24px;
}
.form-actions .field__hint { flex: 1 1 320px; margin: 0; }

/* The verified token, shown at the top of the registration form. */
.join-token {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 22px;
    padding: 12px 16px;
    border-radius: var(--radius-sm);
    background: #DCFCE7;
    color: #166534;
    font-size: var(--fs-sm);
}
.join-token .icon { color: #166534; }

/* Formulir pendaftaran memakai lebar penuh.
 *
 * Batas 1180px dulu masuk akal saat halamannya berupa satu kolom panjang:
 * baris alamat yang membentang selebar layar 27 inci sulit ditelusuri mata.
 *
 * Susunannya sekarang berbeda. Isian berada di kolomnya sendiri dengan panel
 * cetak di sebelah kanan, sehingga lebar tambahan tidak memanjangkan baris
 * teks — ia memberi ruang untuk pratinjau PDF yang lebih besar dan untuk
 * deretan tab yang tidak perlu menggulung. Panjang barisnya dijaga oleh grid
 * di dalam, bukan oleh pembatas di luar. */
.container--form { max-width: none; }

/* Sign-in is three fields; anything wider just makes them look lost. */
.container--auth { max-width: 520px; }

/* `hidden` harus benar-benar menyembunyikan.
 *
 * Atribut `hidden` bawaan peramban hanya `display: none` dari stylesheet UA,
 * dan aturan penulis mana pun yang menetapkan `display` akan mengalahkannya.
 * Di sini `.form-actions { display: flex }` membuat tombol "Kirim Pendaftaran"
 * tetap terlihat di langkah pertama meski JS sudah memasang `hidden` — wizard
 * yang menampilkan tombol kirim sejak langkah satu mengundang orang mengirim
 * formulir yang baru terisi sepertiga.
 *
 * `!important` di sini adalah pemakaian yang benar: ini menegakkan perilaku
 * bawaan HTML, bukan menimpanya. */
[hidden] { display: none !important; }

/* ---- scrollbar -----------------------------------------------------------
   Batang gulir bawaan Windows adalah kotak abu-abu setinggi 17px dengan dua
   tombol panah di ujungnya. Di dalam kartu yang sudutnya membulat, ia terbaca
   sebagai potongan sistem operasi yang menempel di halaman — dan itu paling
   terasa pada deretan tab formulir, tempat batangnya membentang tepat di bawah
   tab yang baru saja dirapikan.

   Dua sintaks dipakai berdampingan, dan keduanya diperlukan:

     `scrollbar-width` / `scrollbar-color` adalah properti CSS standar,
     dipatuhi Firefox dan peramban modern lain.

     `::-webkit-scrollbar` adalah pseudo-elemen lama yang masih menjadi
     satu-satunya cara mengatur bentuk batang di Chrome, Edge, dan Safari.

   Yang sengaja TIDAK dilakukan: menyembunyikan batangnya. Batang gulir adalah
   satu-satunya petunjuk bahwa masih ada isi di luar layar — pada deretan enam
   tab yang hanya muat empat, menyembunyikannya berarti dua tab terakhir tidak
   pernah ditemukan. Carousel boleh menyembunyikannya karena ia punya panah dan
   titik sebagai penggantinya.
   ------------------------------------------------------------------------ */
:root {
    scrollbar-width: thin;
    scrollbar-color: var(--line-firm) transparent;
}

::-webkit-scrollbar { width: 10px; height: 10px; }

::-webkit-scrollbar-track { background: transparent; }

::-webkit-scrollbar-thumb {
    border-radius: 999px;
    background: var(--line-firm);

    /* Border transparan menyisakan jarak di kiri-kanan batang, sehingga ia
       tampak melayang di dalam jalurnya alih-alih memenuhi lebarnya. `clip`
       diperlukan agar latar tidak ikut menembus border itu. */
    border: 2px solid transparent;
    background-clip: content-box;
}

::-webkit-scrollbar-thumb:hover { background: var(--purple-400); background-clip: content-box; }

/* Sudut tempat batang mendatar dan tegak bertemu; tanpa ini ia tetap abu-abu
   sistem meski keduanya sudah diatur. */
::-webkit-scrollbar-corner { background: transparent; }

/* ---- wizard formulir pendaftaran ----------------------------------------
   Dua kolom: isian dan panel cetak. Tahapan dipindah ke deretan tab di atas
   isian — pada layar lebar rail kiri memakan 220px yang lebih berguna untuk
   kolom alamat, dan pada layar sempit rail itu hilang sama sekali sehingga
   tahapannya justru tidak terlihat saat paling dibutuhkan.
   ------------------------------------------------------------------------ */
/* Kolom isian diberi batas 820px di dalam grid, bukan di luar.
 *
 * Itu perbedaan yang menentukan: halaman boleh selebar layar, tetapi satu
 * baris alamat tidak boleh. Membatasi di luar akan ikut mengecilkan panel
 * pratinjau; membatasi di dalam hanya mengunci hal yang memang perlu dikunci.
 *
 * `1fr` pada kolom kedua membuat panel cetak menyerap sisa ruang di layar
 * lebar — pratinjau PDF menjadi lebih besar justru di tempat yang paling
 * mampu menampilkannya. */
.wizard {
    display: grid;
    /* Panel cetak boleh tumbuh, tetapi berhenti di 460px.
     *
     * Dengan `1fr` ia menyerap seluruh sisa layar: pada 1920px panelnya
     * menjadi 965px — lebih lebar daripada kolom isian 820px yang seharusnya
     * jadi pusat perhatian. Pratinjau yang lebih besar dari formulirnya
     * membalik urutan kepentingan halaman ini. */
    /* Kolom isian dilebarkan dari 820px ke 1040px karena deretan tab kini
       berdiri tegak DI DALAMNYA. Tanpa penyesuaian ini, tab selebar 200px akan
       memakan lebar itu dari isian dan satu baris alamat kembali sempit —
       persis hal yang batas 820px itu ada untuk mencegahnya. */
    grid-template-columns: minmax(0, 1040px) minmax(300px, 460px);
    gap: clamp(20px, 3vw, 40px);
    align-items: start;
    justify-content: center;
}

.wizard__side { position: sticky; top: 96px; }

/* ---- tahapan berdiri tegak di kiri isian --------------------------------
 *
 * Sebelumnya deretan tab mendatar di atas isian. Enam tahap dengan judul
 * sepanjang "Dokumen Usaha" tidak muat berdampingan, jadi deretannya harus
 * digulung mendatar — dan tahap yang sedang tidak terlihat berarti orang
 * kehilangan gambaran ada berapa lagi yang tersisa, yang justru satu-satunya
 * alasan wizard ini dibuat.
 *
 * Tegak menyelesaikan itu: keenam judul terbaca sekaligus, penuh, tanpa
 * digulung. Dan karena kolomnya lengket, daftar tahap tetap terlihat sambil
 * orang menggulung isian yang panjang.
 */
.wizard__main {
    display: grid;
    grid-template-columns: minmax(170px, 212px) minmax(0, 1fr);
    column-gap: 26px;
    row-gap: 22px;
    align-items: start;
    min-width: 0;
}

/* Seluruh anak selain tab menempati kolom kanan. Ditulis begini, bukan dengan
   menyebut satu per satu, supaya blok baru yang ditambahkan kelak tidak
   diam-diam mendarat di kolom tab. */
.wizard__main > :not(.wizard__tabs) { grid-column: 2; }

.wizard__tabs {
    grid-column: 1;
    grid-row: 1 / -1;
    position: sticky;
    top: 96px;

    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 2px;
    padding: 8px;
    border: 1px solid var(--line);
    border-radius: var(--radius);
    background: var(--purple-50);
}

.wizard__tab {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 12px;
    border: 0;
    border-radius: 11px;
    background: transparent;
    color: var(--ink-soft);
    font: inherit;
    font-size: var(--fs-2xs);
    font-weight: 600;
    text-align: left;
    cursor: pointer;
    transition: background .2s var(--ease), color .2s var(--ease), box-shadow .2s var(--ease);
}

/* Judul panjang membungkus, tidak dipotong: nama tahap yang terpotong di
   tengah tidak memberi tahu apa pun tentang isinya. */
.wizard__tab-label { line-height: 1.35; }
.wizard__tab:hover { color: var(--purple-700); }

.wizard__tab-no {
    display: grid;
    place-items: center;
    width: 20px;
    height: 20px;
    flex-shrink: 0;
    border-radius: 999px;
    background: var(--purple-100);
    color: var(--purple-700);
    font-size: 11px;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
}

.wizard__tab[aria-selected="true"] {
    background: var(--surface);
    color: var(--ink);
    box-shadow: 0 3px 10px rgba(20, 20, 43, .12);
}
.wizard__tab[aria-selected="true"] .wizard__tab-no {
    background: var(--purple-600);
    color: #fff;
}

/* Tahap yang sudah dilewati ditandai lewat warna nomornya saja, tanpa
   mengganti teks — supaya lebar tab tidak berubah saat orang maju-mundur. */
.wizard__tab.is-done .wizard__tab-no {
    background: #E6F7EA;
    color: #2FA84A;
}

.wizard__nav {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    flex-wrap: wrap;
}
.wizard__count { font-size: var(--fs-2xs); color: var(--muted); }

/* ---- kartu di kolom kanan ----------------------------------------------- */
.sidecard {
    padding: 18px;
    border: 1px solid var(--line);
    border-radius: var(--radius);
    background: var(--surface);
    box-shadow: var(--shadow-sm);
}
.sidecard__title {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 0 0 10px;
    font-size: 14.5px;
}
.sidecard__body { font-size: var(--fs-2xs); color: var(--body); }
.sidecard__btn { width: 100%; margin-top: 14px; justify-content: center; }
.sidecard__note { margin-top: 10px; font-size: var(--fs-2xs); color: var(--muted); }

/* ---- pratinjau PDF -------------------------------------------------------
   Rasio A4 dikunci supaya tinggi bingkainya tidak berubah saat dokumen dimuat
   ulang. Panel yang melompat tiap kali orang mengetik satu huruf lebih
   mengganggu daripada tidak ada pratinjau sama sekali. */
.pdfpreview { position: relative; margin-top: 14px; }

.pdfpreview__frame {
    display: block;
    width: 100%;
    aspect-ratio: 1 / 1.414;
    border: 1px solid var(--line-firm);
    border-radius: var(--radius-sm);
    background: var(--surface-soft);
}

.pdfpreview__state {
    position: absolute;
    top: 8px;
    right: 8px;
    padding: 3px 9px;
    border-radius: 999px;
    background: rgba(20, 20, 43, .78);
    color: #fff;
    font-size: var(--fs-2xs);
}
.pdfpreview__state:empty { display: none; }

.u-hidden { display: none; }

/* Panel cetak berhenti melekat lebih dulu daripada ia menumpuk. Di antara
   1100 dan 1400px kolomnya masih muat, tetapi tingginya melampaui layar
   sehingga `sticky` justru mengunci pratinjau di posisi terpotong. */
@media (max-width: 1400px) {
    .wizard__side { position: static; }
}

@media (max-width: 1100px) {
    /* Satu kolom: isian dulu, panel cetak menyusul di bawahnya. */
    .wizard { grid-template-columns: minmax(0, 1fr); }
}

/* Di bawah 780px, tahapan kembali mendatar di atas isian.
 *
 * Kolom tegak selebar 170px pada layar 400px menyisakan kurang dari 200px
 * untuk isian — kolom alamat menjadi tidak terpakai. Mendatar memang berarti
 * digulung, tetapi digulung lebih baik daripada tidak muat. */
@media (max-width: 780px) {
    .wizard__main { grid-template-columns: minmax(0, 1fr); }
    .wizard__main > :not(.wizard__tabs) { grid-column: 1; }

    .wizard__tabs {
        grid-column: 1;
        grid-row: auto;
        position: static;
        flex-direction: row;
        gap: 6px;
        padding: 6px;
        overflow-x: auto;
        scrollbar-width: thin;
    }

    .wizard__tab { white-space: nowrap; flex: 0 0 auto; }
}

/* Pada layar sempit, pratinjau setinggi A4 memakan seluruh layar sebelum orang
   sempat melihat isiannya. Dipendekkan menjadi jendela yang bisa digulung. */
@media (max-width: 720px) {
    .pdfpreview__frame { aspect-ratio: auto; height: 320px; }
}

/* ---- kotak centang di dalam sebuah field --------------------------------
   Seluruh baris adalah <label>, sehingga tulisannya ikut menjadi sasaran
   klik — target 12 px di samping kotak 16 px adalah target yang meleset di
   ponsel, dan pendaftar akan mengira kotaknya tidak berfungsi.

   Ditempatkan di antara label field dan kolomnya, jadi jaraknya diatur di
   sini dan bukan lewat gaya sebaris pada satu halaman. */
.check {
    display: flex;
    align-items: center;
    gap: 9px;
    margin: 2px 0 8px;
    font-size: var(--fs-2xs);
    color: var(--body);
    cursor: pointer;
    user-select: none;
}

.check input[type="checkbox"] {
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    accent-color: var(--purple-600);
    cursor: pointer;
}

/* Kolom yang sedang mengikuti kolom lain. Dibedakan dengan latar redup, bukan
   hanya dengan atribut readonly yang tak terlihat — kolom yang menolak
   ketikan tanpa alasan yang tampak terbaca sebagai halaman rusak. */
.textarea[readonly],
.input[readonly] {
    background: var(--surface-soft);
    color: var(--muted);
    cursor: not-allowed;
}

/* ---- pilihan cara mengisi tanda tangan ----------------------------------
   Dua tombol yang saling meniadakan, bukan tab: keduanya mengisi kolom yang
   sama, dan yang berubah hanya caranya. Bentuknya sengaja mirip segmented
   control supaya terbaca sebagai satu pilihan, bukan dua tindakan terpisah.
   ------------------------------------------------------------------------ */
.sigmode {
    display: inline-flex;
    gap: 4px;
    padding: 4px;
    margin-bottom: 12px;
    border: 1px solid var(--line);
    border-radius: 999px;
    background: var(--surface-soft);
}

.sigmode__btn {
    padding: 7px 16px;
    border: 0;
    border-radius: 999px;
    background: transparent;
    color: var(--body);
    font: inherit;
    font-size: var(--fs-2xs);
    font-weight: 600;
    cursor: pointer;
    transition: background .18s ease, color .18s ease;
}
.sigmode__btn:hover { color: var(--purple-700); }
.sigmode__btn.is-active {
    background: var(--surface);
    color: var(--ink);
    box-shadow: 0 2px 6px rgba(20, 20, 43, .12);
}

.sigupload { margin-bottom: 12px; }

/* ---- kotak tanda tangan -------------------------------------------------
   Kanvas dilebarkan ke lebar induknya lewat CSS, sementara atribut width dan
   height pada elemennya tetap 900x300 — itu resolusi gambar yang dihasilkan,
   bukan ukuran tampilnya. Penskalaan antara keduanya ditangani di app.js;
   tanpa itu garis akan meleset dari jari.

   `touch-action: none` wajib ada. Tanpanya, menggambar di ponsel ikut
   menggulung halaman dan goresan terputus di tengah.
   ------------------------------------------------------------------------ */
.signature {
    border: 1px dashed var(--line-firm);
    border-radius: var(--radius-sm);
    background: var(--surface-soft);
    overflow: hidden;
}

.signature__pad {
    display: block;
    width: 100%;
    height: auto;
    aspect-ratio: 3 / 1;
    touch-action: none;
    cursor: crosshair;
}

.signature__bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 8px 10px;
    border-top: 1px dashed var(--line-firm);
    background: var(--surface);
}

.signature__hint {
    font-size: var(--fs-2xs);
    color: var(--muted);
}

/* Judul blok yang menyandang keterangan "opsional", supaya kata itu terbaca
   sebagai label dan bukan bagian dari judulnya. */
.form-block__note {
    margin-left: 8px;
    padding: 2px 8px;
    border-radius: 999px;
    background: var(--purple-100);
    color: var(--purple-700);
    font-size: var(--fs-2xs);
    font-weight: 600;
    vertical-align: middle;
}

/* ---- numbered steps inside a panel --------------------------------------
   The activation pages first borrowed `.qa-list` for this and it read as a
   transplant: that list is a landing-page ornament — shadowed purple badges,
   dashed rules between entries, an entrance animation — and its animation is
   gated on `.tabs.is-visible`, so outside a tab block it never even runs. Next
   to a form it was decoration competing with the fields.

   This is the quiet version. Same numbering idea, sized and coloured to sit
   with `.field__label` and `.field__hint` rather than shout over them: flat
   badges, no rules, no animation. Instructions before a form are something you
   read once and then stop looking at, and they should behave that way.
   ------------------------------------------------------------------------ */
.steps {
    counter-reset: step;
    display: grid;
    gap: 12px;
    margin: 0 0 24px;
    padding: 0;
    list-style: none;
}

/* Indent plus an absolutely placed badge — NOT `display: grid` on the <li>.
 *
 * Grid was tried and it broke the text apart. Every child element of a grid
 * container becomes a grid item, and runs of bare text between them become
 * anonymous items of their own. So a step reading
 *
 *     Pasang aplikasi <strong>Google Authenticator</strong> di HP kamu.
 *
 * is not one paragraph to grid — it is three items: "Pasang aplikasi", the
 * <strong>, and "di HP kamu.". They get dealt into a two-column track in turn,
 * which put the bold words in the badge column, underneath the number, on top
 * of the line below. The sentence collided with itself.
 *
 * Padding and an absolute badge keep the whole sentence in normal inline flow,
 * so it wraps as prose no matter what markup is inside it. 34px = the badge's
 * 22px plus a 12px gutter. */
.steps li {
    counter-increment: step;
    position: relative;
    padding-left: 34px;
    font-size: var(--fs-sm);
    line-height: 1.65;
    color: var(--body);
}

.steps li::before {
    content: counter(step);
    position: absolute;
    left: 0;

    /* Sits on the first line of text rather than on the top of the box. */
    top: 2px;

    display: grid;
    place-items: center;
    width: 22px;
    height: 22px;

    border-radius: 999px;
    background: var(--purple-100);
    color: var(--purple-700);
    font-size: var(--fs-2xs);
    font-weight: 700;
    font-variant-numeric: tabular-nums;
}

/* The same list after a form, where it is telling somebody what to do next
   rather than what to do first — so it needs the space above, not below. */
.steps--after { margin: 0; }

/* ---- the manual TOTP key ------------------------------------------------
   Shown when the camera will not read the QR. It is typed character by
   character into a phone, so it is set in tabular figures, spaced in groups,
   and allowed to wrap anywhere — a key that overflows its box on a narrow
   screen is one the agent cannot finish reading.

   Its own tokens rather than a literal grey: this box was hard-coded #F3F4F6,
   which is a light-theme decision baked into markup. */
.otp-key {
    display: block;
    padding: 12px 14px;
    border: 1px dashed var(--line-firm);
    border-radius: var(--radius-sm);
    background: var(--surface-soft);
    color: var(--ink);
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: var(--fs-sm);
    font-variant-numeric: tabular-nums;
    letter-spacing: .14em;
    text-align: center;

    /* Wraps at the spaces between groups, never inside one.
     *
     * This said `word-break: break-all`, and on a narrow screen it split a
     * group down the middle — "2KWZ DYLR CIEB OGU3 JM7 / 7 TNYU BPUK BTLN".
     * The key is grouped in fours precisely so it can be typed without losing
     * one's place, and a break inside a group destroys the only thing the
     * grouping was for. `break-word` still rescues a box too narrow for a
     * single group, which four characters never is. */
    word-break: normal;
    overflow-wrap: break-word;
}

/* ---- the QR itself ------------------------------------------------------
   Centred, bounded, and given the same rounded edge as the panel it sits in.
   `height: auto` matters: the width/height attributes are the intrinsic size,
   and without this the image keeps the declared height while the width shrinks
   on a narrow screen — the distortion this stylesheet already fixes globally
   for images, restated here because this one carries a scannable code and a
   squashed QR is an unscannable one. */
.qr-figure {
    margin: 0 0 24px;
    text-align: center;
}

.qr-figure img {
    display: inline-block;
    width: 320px;
    max-width: 100%;
    height: auto;
    border-radius: var(--radius-sm);
}

/* ---- FAQ, laid out across the screen ------------------------------------
   Same grid rule as the registration form: one group per cell, `auto-fit`
   with a minimum, so the browser decides how many columns fit. Cells align
   to the top — a two-question group should not stretch to match a
   five-question one.

   The 380px floor is what keeps an answer's line length readable; below it
   a second column would win space at the cost of the text.
   ------------------------------------------------------------------------ */
.faq-layout {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(380px, 100%), 1fr));
    gap: clamp(18px, 2.5vw, 26px);
    align-items: start;
}

.faq-block__title {
    margin-bottom: 18px;
    padding-bottom: 14px;
    border-bottom: 1px solid var(--line);
    font-size: var(--fs-lg);
}

/* A hairline between questions rather than around each one: inside a card
   already bounded by a border, boxing every question again is noise. */
.faq-item + .faq-item {
    margin-top: 18px;
    padding-top: 18px;
    border-top: 1px dotted var(--purple-200);
}

.faq-item__q {
    margin-bottom: 8px;
    font-size: var(--fs-md);
    line-height: 1.45;
}

.faq-item__a {
    color: var(--body);
    font-size: var(--fs-sm);
    line-height: 1.75;
}
.faq-item__a p { margin: 0; }
.faq-item__a p + p { margin-top: 10px; }

/* The captcha answer is one or two digits — a full-width box invites the
   suspicion that something longer is expected. */
.input--captcha { max-width: 140px; letter-spacing: .08em; }

/* The drawn challenge on the sign-in form. Its intrinsic size travels with
   the markup so the panel does not jump once the image decodes, and the box
   is capped so a narrow phone shrinks it rather than scrolling sideways. */
.captcha-image {
    display: block;
    max-width: 100%;
    height: auto;
    margin-bottom: 10px;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    /* Nothing here is worth selecting, and a drag-select over it looks like
       a bug rather than a feature. */
    user-select: none;
}

/* Five characters, retyped rather than read back — wide spacing and a
   monospaced face make a misread I/1 or O/0 less likely. */
.input--code {
    max-width: 220px;
    letter-spacing: .22em;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    text-transform: uppercase;
}

.input[aria-invalid="true"], .textarea[aria-invalid="true"], .select[aria-invalid="true"] {
    border-color: var(--pink-500);
}

.field-error { font-size: var(--fs-2xs); font-weight: 500; color: #DB2777; }

.alert {
    display: flex;
    align-items: flex-start;
    gap: 12px;
    padding: 14px 18px;
    margin-bottom: 22px;
    border-radius: var(--radius-sm);
    border: 1px solid transparent;
    font-size: var(--fs-sm);
}
.alert--success { background: #ECFDF5; border-color: #A7F3D0; color: #065F46; }
.alert--error   { background: #FEF2F2; border-color: #FECACA; color: #991B1B; }

/* Partial outcomes: the visitor's part succeeded, ours did not finish. Amber
   rather than green, so it is never mistaken at a glance for a clean success. */
.alert--warning { background: #FFFBEB; border-color: #FDE68A; color: #92400E; }
.alert__list { margin: 6px 0 0; padding-left: 1.1rem; list-style: disc; }

/* --------------------------------------------------------------------------
   16. Info panels (contact page etc.)
   -------------------------------------------------------------------------- */
.panel {
    padding: clamp(24px, 3vw, 34px);
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm);
}
.panel--brand {
    background: var(--grad-cta);
    border-color: transparent;
    color: #fff;
    box-shadow: var(--shadow-cta);
}
.panel--brand h2, .panel--brand h3 { color: #fff; }

.contact-list { display: grid; gap: 14px; }
.contact-list li { display: flex; align-items: flex-start; gap: 12px; font-size: var(--fs-sm); line-height: 1.6; }
.contact-list .icon { margin-top: 3px; color: var(--purple-500); }

.split { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); gap: clamp(24px, 4vw, 44px); align-items: start; }

/* ---- certificate lookup -------------------------------------------------
   Form and result side by side, so the visitor can correct a typo against
   the answer without scrolling between the two.
   ------------------------------------------------------------------------ */
.cert {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: clamp(24px, 4vw, 40px);
    align-items: start;
}

/* The placeholder shown before anything has been submitted: quiet, and
   clearly not a result. */
.panel--muted {
    display: grid;
    justify-items: center;
    gap: 10px;
    background: var(--surface-soft);
    box-shadow: none;
    text-align: center;
    color: var(--muted);
}
.panel--muted .icon { width: 34px; height: 34px; color: var(--purple-200); }
.panel--muted p { font-size: var(--fs-sm); }

/* A coloured edge rather than a coloured panel: the status is the exception,
   the details underneath still need to read as ordinary text. */
/* Arrives with the page after the redirect, so it animates on load. Reduced
   motion flattens this through the blanket rule at the end of the file. */
.cert-card { border-left: 4px solid var(--purple-400); animation: mrkg-rise .65s var(--ease) both; }
.cert-card--success { border-left-color: #2E9E43; }
.cert-card--warning { border-left-color: var(--orange-500); }
.cert-card--danger  { border-left-color: #DC2626; }

.cert-card__status {
    display: inline-flex;
    align-items: center;
    gap: 7px;
    margin-bottom: 14px;
    padding: 6px 14px;
    border-radius: 999px;
    background: var(--purple-100);
    color: var(--purple-700);
    font-size: var(--fs-xs);
    font-weight: 700;
}
.cert-card__status[data-tone="success"] { background: #DCFCE7; color: #166534; }
.cert-card__status[data-tone="warning"] { background: var(--orange-100); color: #9A3412; }
.cert-card__status[data-tone="danger"]  { background: #FEE2E2; color: #991B1B; }

/* The embedded PDF. A fixed aspect keeps the frame the shape of the document
   instead of a slot the viewer has to scroll inside; A4 is 1:1.414. */
.cert-pdf {
    margin-top: 20px;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    background: var(--surface-soft);
    overflow: hidden;
}
.cert-pdf__frame {
    display: block;
    width: 100%;
    aspect-ratio: 1 / 1.414;

    /* Capped so a tall document does not push the rest of the page out of
       reach on a laptop screen. */
    max-height: 70vh;
    border: 0;
}

.cert-card__rows { margin: 16px 0 0; }

.cert-card__row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 20px;
    padding: 11px 0;
    font-size: var(--fs-sm);
}
.cert-card__row + .cert-card__row { border-top: 1px dotted var(--purple-200); }

.cert-card__row dt { color: var(--body); }
.cert-card__row dd {
    margin: 0;
    font-weight: 600;
    color: var(--ink);
    text-align: right;

    /* Serials and phone numbers are read character by character, so they get
       even-width digits and a little tracking. */
    font-variant-numeric: tabular-nums;
    letter-spacing: .01em;
}

/* --------------------------------------------------------------------------
   17. Pagination / empty state
   -------------------------------------------------------------------------- */
.pagination { display: flex; justify-content: center; gap: 8px; margin-top: 42px; }
.pagination a, .pagination span {
    display: grid;
    place-items: center;
    min-width: 40px;
    height: 40px;
    padding-inline: 12px;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    background: #fff;
    color: var(--ink-soft);
    font-size: var(--fs-sm);
    font-weight: 500;
}
.pagination a:hover { border-color: var(--purple-400); color: var(--purple-700); }
.pagination .is-current { background: var(--purple-600); border-color: var(--purple-600); color: #fff; }
.pagination .is-disabled { opacity: .4; }

.empty-state {
    padding: 60px 24px;
    text-align: center;
    color: var(--muted);
    border: 1px dashed var(--line);
    border-radius: var(--radius);
    background: var(--surface-soft);
}
.empty-state .icon { width: 40px; height: 40px; margin: 0 auto 12px; color: var(--purple-200); }

/* --------------------------------------------------------------------------
   18. Footer
   -------------------------------------------------------------------------- */
.site-footer {
    padding: clamp(48px, 6vw, 72px) 0 28px;
    background: var(--footer-bg);
    color: var(--footer-text);
    font-size: var(--fs-sm);
}

.site-footer__grid {
    display: grid;
    grid-template-columns: 1.6fr repeat(3, .9fr) 1.25fr;
    gap: clamp(24px, 3vw, 40px);
    padding-bottom: 36px;
}

.site-footer__desc { margin: 18px 0 20px; line-height: 1.75; max-width: 34ch; }

.site-footer__heading {
    margin-bottom: 18px;
    color: #fff;
    font-size: var(--fs-md);
    font-weight: 600;
}

.site-footer__col ul { display: grid; gap: 11px; }

/* An underline that grows from the left on hover, rather than appearing whole.
   `inline-block` is what gives the pseudo-element a box to measure against;
   without it the rule would span the whole column. */
.site-footer__col a {
    display: inline-block;
    position: relative;
    color: var(--footer-text);
    transition: color .2s var(--ease), transform .2s var(--ease);
}
.site-footer__col a::after {
    content: "";
    position: absolute;
    left: 0;
    bottom: -2px;
    width: 100%;
    height: 1px;
    background: currentColor;
    transform: scaleX(0);
    transform-origin: left;
    transition: transform .25s var(--ease);
}
.site-footer__col a:hover {
    color: #fff;
    transform: translateX(2px);
}
.site-footer__col a:hover::after,
.site-footer__col a:focus-visible::after { transform: scaleX(1); }

.site-footer .contact-list .icon { color: var(--purple-400); }
.site-footer .contact-list a { color: var(--footer-text); }
.site-footer .contact-list a:hover { color: #fff; }

.social-list { display: flex; gap: 10px; }
.social-list__link {
    display: grid;
    place-items: center;
    width: 36px; height: 36px;
    border: 1px solid var(--footer-line);
    border-radius: 50%;
    color: var(--footer-text);
    transition: background .2s var(--ease), color .2s var(--ease), border-color .2s var(--ease), transform .2s var(--ease);
}
.social-list__link:hover {
    background: var(--purple-600);
    border-color: var(--purple-600);
    color: #fff;
    transform: translateY(-2px);
}

.site-footer__bottom {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 18px;
    padding-top: 24px;
    border-top: 1px solid var(--footer-line);
    font-size: var(--fs-xs);
}
.site-footer__copy, .site-footer__made { margin: 0; }
.site-footer__made { display: inline-flex; align-items: center; gap: 6px; }

.trust-badges { display: flex; flex-wrap: wrap; gap: 22px; }
.trust-badges li { display: inline-flex; align-items: center; gap: 7px; }
.trust-badges .icon { color: var(--purple-400); }

/* --------------------------------------------------------------------------
   19. Floating WhatsApp button
   Collapsed to a circle; the label slides out on hover/focus. On small
   screens it stays a circle so it never covers content.
   -------------------------------------------------------------------------- */
/*
 * Pinned to the bottom-right corner and kept there. The button never moves in
 * response to scrolling — a control that shifts position under the cursor is
 * a control you have to chase. The entrance is a fade only, with no transform,
 * so even on first paint it does not travel.
 */
.wa-float {
    position: fixed;
    right: clamp(16px, 3vw, 28px);
    bottom: clamp(16px, 3vw, 28px);
    z-index: 90;

    animation: wa-enter .55s var(--ease) .45s backwards;
}

@keyframes wa-enter {
    from { opacity: 0; }
    to   { opacity: 1; }
}

.wa-float__btn {
    position: relative;
    display: flex;
    align-items: center;
    gap: 0;
    height: 56px;
    padding: 0 16px;
    border-radius: 999px;
    background: #25D366;
    color: #fff;
    box-shadow: 0 8px 24px rgba(37, 211, 102, .38), 0 2px 6px rgba(20, 20, 43, .18);
    overflow: hidden;
    transition: gap .28s var(--ease), padding .28s var(--ease),
                box-shadow .28s var(--ease), transform .28s var(--ease),
                background .2s var(--ease);
}
.wa-float__btn:hover,
.wa-float__btn:focus-visible {
    gap: 10px;
    padding-right: 22px;
    background: #1EBE5A;
    color: #fff;
    transform: translateY(-2px);
    box-shadow: 0 12px 30px rgba(37, 211, 102, .48), 0 3px 8px rgba(20, 20, 43, .2);
}

.wa-float__icon {
    position: relative;
    z-index: 1;
    width: 26px;
    height: 26px;
    flex-shrink: 0;
}

/* The label is laid out at zero width and grows on hover, so the collapsed
   button is a perfect circle without any JS measuring. */
.wa-float__text {
    position: relative;
    z-index: 1;
    max-width: 0;
    white-space: nowrap;
    font-size: var(--fs-sm);
    font-weight: 600;
    opacity: 0;
    transition: max-width .28s var(--ease), opacity .22s var(--ease);
}
.wa-float__btn:hover .wa-float__text,
.wa-float__btn:focus-visible .wa-float__text {
    max-width: 15rem;
    opacity: 1;
}

/* Slow outward ripple, to draw the eye without shouting. */
.wa-float__pulse {
    position: absolute;
    left: 16px;
    top: 50%;
    width: 26px;
    height: 26px;
    margin: -13px 0 0 0;
    border-radius: 50%;
    background: #25D366;
    animation: wa-pulse 2.6s var(--ease) infinite;
    pointer-events: none;
}

@keyframes wa-pulse {
    0%   { transform: scale(1);   opacity: .55; }
    70%  { transform: scale(2.4); opacity: 0; }
    100% { transform: scale(2.4); opacity: 0; }
}

.wa-float__btn:hover .wa-float__pulse { animation-play-state: paused; opacity: 0; }

@media (prefers-reduced-motion: reduce) {
    .wa-float { animation: none; }
    .wa-float__pulse { animation: none; opacity: 0; }
}

/* Keep it a circle on phones so it never sits on top of body text. */
@media (max-width: 640px) {
    .wa-float__btn { height: 52px; padding: 0 13px; }
    .wa-float__btn:hover,
    .wa-float__btn:focus-visible { gap: 0; padding-right: 13px; }
    .wa-float__text { display: none; }
    .wa-float__pulse { left: 13px; }
}

@media print {
    .wa-float { display: none !important; }
}

/* --------------------------------------------------------------------------
   20. Utilities
   The Content-Security-Policy does not allow inline `style` attributes
   (style-src-attr is 'self', not 'unsafe-inline'), so the handful of
   one-off spacing tweaks the templates need live here as classes.
   -------------------------------------------------------------------------- */
.u-mt-8  { margin-top: 8px; }
.u-mt-10 { margin-top: 10px; }
.u-mt-14 { margin-top: 14px; }
.u-mt-16 { margin-top: 16px; }
.u-mt-18 { margin-top: 18px; }
.u-mt-22 { margin-top: 22px; }
.u-mt-24 { margin-top: 24px; }
.u-mt-26 { margin-top: 26px; }
.u-mt-34 { margin-top: 34px; }
.u-mt-36 { margin-top: 36px; }
.u-mt-56 { margin-top: 56px; }

.u-mb-22 { margin-bottom: 22px; }
.u-mb-24 { margin-bottom: 24px; }

.u-center { text-align: center; }
.u-h4 { font-size: var(--fs-md); }

/* text and links sitting on the purple brand panel */
.u-on-brand { color: rgba(255, 255, 255, .86); }
.u-link-light { color: #fff; }
.u-link-light:hover { color: #fff; text-decoration: underline; }

.req { color: var(--pink-500); }

.divider { border: 0; border-top: 1px solid var(--line); margin: 26px 0; }
.divider--light { border-top-color: rgba(255, 255, 255, .22); }

/* horizontal row of filter pills above a listing */
.filter-bar {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 10px;
    margin-bottom: 34px;
}

/* --------------------------------------------------------------------------
   21. Responsive
   -------------------------------------------------------------------------- */
@media (max-width: 1024px) {
    /* Below this the four steps wrap onto two rows and a single horizontal
       rail would run through empty space, so it goes. */
    .flow::before { display: none; }

    .site-footer__grid { grid-template-columns: 1.4fr 1fr 1fr; }
    .site-footer__contact { grid-column: 2 / -1; }
    .carousel__btn--prev { left: -6px; }
    .carousel__btn--next { right: -6px; }
}

@media (max-width: 900px) {
    .hero__grid,
    .about__grid,
    .intro-band,
    .tabs-layout,
    .estimate,
    .cert,
    .videos-split,
    .split { grid-template-columns: minmax(0, 1fr); }

    .tabs-layout__media img { max-width: 300px; }

    /* Collapsing the grid above is not enough on its own: this child is pinned
       to `grid-column: 2` and spans both rows for the wide layout. With one
       column declared, asking for column 2 makes the browser invent a second
       one, and the panel keeps its two-column width — running off the side of
       a phone. Released here so it simply follows the list. */
    .estimate__media {
        grid-column: 1;
        grid-row: auto;
    }

    .about__grid .frame { order: -1; }

    /* Stacked, the device would swallow the fold; it also loses its column
       so the cap has to come down with it. */
    .intro-band__media img { max-height: min(46vh, 340px); }
    .hero__lead { max-width: none; }

    .nav-toggle { display: grid; place-items: center; }

    .site-nav {
        position: absolute;
        left: var(--gutter);
        right: var(--gutter);
        top: calc(100% + 8px);
        flex-direction: column;
        align-items: stretch;
        gap: 6px;
        padding: 14px;
        background: #fff;
        border: 1px solid var(--line);
        border-radius: var(--radius);
        box-shadow: var(--shadow-lg);
        display: none;
    }
    .site-nav.is-open { display: flex; }

    .site-nav__list { flex-direction: column; align-items: stretch; gap: 2px; }
    .site-nav__link { padding: 11px 12px; border-radius: var(--radius-sm); }
    .site-nav__link:hover { background: var(--purple-50); }
    .site-nav__link.is-active { background: var(--purple-50); }
    .site-nav__link::after, .site-nav__link.is-active::before { display: none; }
    .site-nav__cta { margin-top: 8px; }

    .carousel__item { flex-basis: calc((100% - 2 * 18px) / 3); }
}

@media (max-width: 640px) {
    .site-footer__grid { grid-template-columns: 1fr 1fr; }
    .site-footer__brand { grid-column: 1 / -1; }
    .site-footer__contact { grid-column: 1 / -1; }
    .site-footer__bottom { flex-direction: column; align-items: flex-start; }

    .carousel__item { flex-basis: calc((100% - 18px) / 2); }
    .carousel__btn { display: none; }

    .cta { flex-direction: column; align-items: flex-start; text-align: left; }
    .cta__actions .btn { flex: 1 1 auto; }

    .hero__actions .btn { flex: 1 1 100%; }

    /* Two long sentences will not sit side by side on a phone. */
    .tabs__list { flex-direction: column; }
    .tabs__tab { text-align: left; }

    /* Below this the illustration column would leave the text about twelve
       characters wide, so the two stack instead. */
    .benefit { grid-template-columns: minmax(0, 1fr); justify-items: start; }
    .benefit__media { width: clamp(96px, 26vw, 140px); }

    /* Centred inside its own column when there is one; left-aligned with the
       text once the two are stacked. */
    .benefit__media--icon { justify-self: start; }
}

@media (max-width: 420px) {
    .site-footer__grid { grid-template-columns: 1fr; }
}

/* --------------------------------------------------------------------------
   22. Motion / print
   -------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
    *, *::before, *::after {
        animation-duration: .001ms !important;
        /* Without this the staggered entrances (hero headline, lead, buttons,
           photo) would still hold their from-state — opacity 0 — for the whole
           delay. Collapsing the duration alone would leave a reduced-motion
           visitor looking at half a second of blank hero. */
        animation-delay: 0s !important;
        animation-iteration-count: 1 !important;
        transition-duration: .001ms !important;
        scroll-behavior: auto !important;
    }
}

/*
 * Reveal-on-scroll.
 *
 * Content must never depend on JavaScript to become visible. The hidden
 * starting state is therefore applied only where scripting is actually
 * available; anywhere else — JS disabled, blocked, or failed to parse — the
 * rule never applies and everything renders normally. app.js also carries a
 * timeout that force-reveals anything still hidden, in case the observer
 * never fires.
 */
/* Entrances use the long-tailed curve and run a touch longer. The difference
   is small on paper and clear on screen: the element arrives and settles,
   rather than sliding at a constant rate and stopping dead.

   ---- on the timings across this file ----------------------------------
   Every one-shot entrance on the site was slowed by roughly half again, so the
   effect can actually be seen rather than being over before the eye finds it.
   That covers this transition, the `.stagger` delays below, the hero and
   inner-page banner animations, and the numbered lists.

   Two families were deliberately left alone, and should stay that way:

     - Interaction feedback — button hovers, card lifts, focus rings, the tab
       strip. These answer something the visitor just did, and a slow answer
       does not read as elegant, it reads as a slow site.
     - Idle loops — the floats and breathes. They are already measured in
       seconds and slowing them further makes them stop reading as motion.

   Dialogs sit between the two and were nudged rather than stretched. */
.reveal { transition: opacity 1.05s var(--ease-entrance), transform 1.05s var(--ease-entrance); }
.reveal.is-visible { opacity: 1; transform: none; }

@media (scripting: enabled) {
    .reveal { opacity: 0; transform: translateY(18px); }
}

/* ---- staggered reveal ---------------------------------------------------
   Items in a row all cross the observer's threshold at the same moment, so
   without a delay they arrive as one block. A small step per item makes the
   row read left to right. Delay is on the transition, not an animation, so
   the reduced-motion override below still flattens it to nothing.

   Numbered rather than driven by a custom property because `style="--i:2"`
   would be an inline style, and the site's CSP allows none.
   ------------------------------------------------------------------------ */
.stagger > .reveal:nth-child(2)  { transition-delay: .12s; }
.stagger > .reveal:nth-child(3)  { transition-delay: .24s; }
.stagger > .reveal:nth-child(4)  { transition-delay: .36s; }
.stagger > .reveal:nth-child(5)  { transition-delay: .48s; }
.stagger > .reveal:nth-child(6)  { transition-delay: .60s; }
.stagger > .reveal:nth-child(7)  { transition-delay: .72s; }
.stagger > .reveal:nth-child(8)  { transition-delay: .84s; }

/* ---- entrance for content that appears after a request -----------------
   The certificate result is not scrolled into view — it is simply there on
   the next page load, so it animates on arrival rather than on intersection.
   ------------------------------------------------------------------------ */
@keyframes mrkg-rise {
    from { opacity: 0; transform: translateY(14px); }
    to   { opacity: 1; transform: none; }
}

@keyframes mrkg-spin {
    to { transform: rotate(360deg); }
}

/* ---- navigation progress bar --------------------------------------------
   Created by app.js, so it never exists on a page without scripting. Scaled
   rather than resized: transform is composited, so the bar cannot make the
   page it sits above stutter while it grows.
   ------------------------------------------------------------------------ */
.page-progress {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 200;
    width: 100%;
    height: 3px;
    background: var(--grad-logo);
    transform: scaleX(0);
    transform-origin: 0 50%;
    opacity: 0;
    pointer-events: none;
    transition: transform .25s var(--ease), opacity .3s var(--ease);
}
/* Appears at once, leaves gently. A fade-in is wasted here: the page it is
   reporting on often arrives in under 200ms, so a 300ms fade left the bar
   still half transparent when the document was replaced — which read as the
   bar never showing at all. Fading out still looks better, so only the
   incoming transition is cancelled. */
.page-progress.is-active {
    opacity: 1;
    transition: transform .2s var(--ease), opacity 0s;
}

/* Steps rather than a computed width — see the note in app.js. */
.page-progress.is-step-1 { transform: scaleX(.28); }
.page-progress.is-step-2 { transform: scaleX(.45); }
.page-progress.is-step-3 { transform: scaleX(.68); }
.page-progress.is-step-4 { transform: scaleX(.88); }
.page-progress.is-done   { transform: scaleX(1); opacity: 0; }

/* ---- page arrival -------------------------------------------------------
   A short fade as the document paints, so a new page eases in rather than
   snapping. It ends at full opacity, so a browser that never runs the
   animation still shows the page — nothing here depends on scripting.
   ------------------------------------------------------------------------ */
@keyframes mrkg-fade-in {
    from { opacity: 0; }
    to   { opacity: 1; }
}

#main { animation: mrkg-fade-in .45s var(--ease) both; }

/* ---- button loading state ----------------------------------------------
   The label is hidden with `color: transparent` rather than removed, so the
   button keeps its exact width and the row does not jump on submit.
   ------------------------------------------------------------------------ */
.btn.is-loading {
    position: relative;
    color: transparent;
    pointer-events: none;
}
.btn.is-loading::after {
    content: "";
    position: absolute;
    inset: 0;
    width: 18px;
    height: 18px;
    margin: auto;
    border: 2px solid rgba(255, 255, 255, .45);
    border-top-color: #fff;
    border-radius: 50%;
    animation: mrkg-spin .6s linear infinite;
}

/* Outline buttons spin in ink, not white — white on white is invisible. */
.btn--outline.is-loading::after,
.btn--white.is-loading::after {
    border-color: rgba(124, 58, 237, .3);
    border-top-color: var(--purple-600);
}

@media (prefers-reduced-motion: reduce) {
    .reveal { opacity: 1; transform: none; }
    .stagger > .reveal { transition-delay: 0s !important; }

    /* The spinner is the one animation kept: it is feedback that something is
       happening, not decoration. Slowed right down instead of removed — the
       `!important` is needed to beat the blanket `*` rule further down, which
       would otherwise freeze it mid-turn. */
    .btn.is-loading::after { animation-duration: 1.6s !important; }
}

@media print {
    .site-header, .site-footer, .carousel__btn, .cta { display: none !important; }
    body { color: #000; }
}

/* ---- dialog konfirmasi sebelum kirim -------------------------------------
   Memakai kerangka .done yang sama dengan dialog hasil, hanya lebih lebar:
   isinya daftar, bukan satu kalimat. */
.done__panel--wide { max-width: 560px; text-align: left; }
.done__panel--wide .done__mark,
.done__panel--wide .done__title,
.done__panel--wide .done__lead { text-align: center; }
.done__panel--wide .done__mark { margin-inline: auto; }

.confirm {
    display: grid;
    gap: 1px;
    margin: 22px 0 0;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    background: var(--line);
    overflow: hidden;
    max-height: 46vh;
    overflow-y: auto;
}

.confirm__row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 16px;
    padding: 10px 14px;
    background: var(--surface);
    font-size: var(--fs-2xs);
}

.confirm__key { color: var(--muted); flex: 0 0 auto; }

/* Nama berkas bisa panjang sekali. Dibiarkan membungkus, tidak dipotong:
   nama yang terpotong justru menyembunyikan bagian yang membedakan satu foto
   dari foto lain — persis hal yang dialog ini ada untuk diperiksa. */
.confirm__val {
    color: var(--ink);
    font-weight: 600;
    text-align: right;
    overflow-wrap: anywhere;
}

/* Yang tidak dilampirkan ditandai, bukan disembunyikan: berkas opsional yang
   kosong adalah hal yang sah, tetapi orang tetap berhak tahu sebelum kirim. */
.confirm__row--none .confirm__val { color: var(--muted); font-weight: 500; font-style: italic; }

.confirm__acts {
    display: flex;
    gap: 10px;
    justify-content: flex-end;
    flex-wrap: wrap;
    margin-top: 22px;
}

/* Tombol dialog hasil dibuat selebar panel (.done__panel .btn), karena di sana
   memang cuma ada satu. Di dialog konfirmasi ada dua dan keduanya berdampingan,
   jadi lebarnya dikembalikan ke ukuran isinya. */
.done__panel--wide .confirm__acts .btn { width: auto; }
