/* MyImagination — one stylesheet, no build step, no framework. */

:root {
  /* Tells the browser to render its own furniture — scrollbars, form controls,
     spinner arrows — to match. Without it those stay light on a dark page. */
  color-scheme: light;

  /* Palette = the drawer (AUDIT_FRONTEND P1-4, Alberd 2026-10-30: "Samakan ke
     #ff9016"). The brand orange is the Pumpkin Orange spool the products are
     printed in; white and near-black are the other two spools. Cyan survives
     only where it MEANS "info" (.notice.cyan, .pill.ship); violet is gone.
     Three roles are kept strictly apart, because conflating them is what broke
     contrast the last times the palette changed:
       --brand / --sun   are FILLS. Never text, never a lone line: #ff9016 on the
                         cream page is 2.2:1, so text on it is --on-bright.
       --brand-line      focus rings, hover/selected borders, radio fill: the
                         things that need 3:1 against the page on their own.
       --*-ink, --brand-deep   text on the page and on the soft tints. */
  --brand: #ff9016;
  --brand-line: #c96b00;
  /* Dark orange of the same hue (32 degrees). Checked on all four light
     surfaces that carry links and prices: --bg 5.44, --card 5.58, section.alt
     (--bg-alt) 4.92, orange tint 5.10. */
  --brand-deep: #a35200;
  --brand-soft: #fff3e6;
  --brand-ink: #9e4f00;
  --cyan-soft: #e0f7fc;
  --cyan-ink: #00758d;
  --sun: #fbbf24;
  --sun-soft: #fff8e1;

  /* Text placed on any bright fill. Deliberately the same in light and dark:
     a bright orange chip is bright in both themes, so what reads on it does
     not change either. */
  --on-bright: #17110d;

  --bg: #fffcf5;
  --bg-alt: #ffeedd;
  --card: #ffffff;
  --ink: #17110d;
  --ink-soft: #4d443c;
  --muted: #766c63;
  --line: #ffe0c6;
  --line-strong: #ffc79a;

  --good: #059669;
  --good-soft: #e7f8f2;
  --good-ink: #047b56;
  --warn: #d12222;
  --warn-soft: #fef1f1;
  --warn-deep: #a81a1a;

  /* Radius carries meaning instead of one value everywhere. A single 16px
     radius on every surface is one of the four things that made this read as
     generated output: nothing had a reason to differ, so nothing did.
       sheet   — panels, cards, tables. Nearly square, because these are
                 documents you read, not chips you press.
       control — buttons and inputs. Rounded enough to invite a tap.
       pill    — only things that are genuinely pill-shaped. */
  --radius: 3px;          /* sheet — kept under the old name; used in 4 places */
  --radius-lg: 4px;
  --r-control: 7px;
  --r-pill: 999px;

  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;

  /* Surfaces are separated by a rule, not by a glow. A soft shadow under every
     card is the other half of the generated look, and it also flattens
     hierarchy: when everything floats, nothing does. Shadow is now reserved
     for things that genuinely sit above the page — a toast, a sticky bar. */
  --shadow: none;
  --shadow-lift: none;
  --shadow-float: 0 2px 6px rgba(120, 80, 50, .10), 0 16px 36px -16px rgba(120, 80, 50, .34);

  --step--1: .875rem; --step-0: 1rem;    --step-1: 1.125rem;
  --step-2: 1.375rem; --step-3: 1.75rem; --step-4: 2.25rem; --step-5: 3rem;

  --sp-1: .25rem; --sp-2: .5rem;  --sp-3: .75rem; --sp-4: 1rem;
  --sp-5: 1.5rem; --sp-6: 2rem;   --sp-7: 3rem;   --sp-8: 4rem;

  --ease-out: cubic-bezier(.22, .61, .36, 1);
  --ease-spring: cubic-bezier(.34, 1.56, .64, 1);
  --dur-fast: 140ms; --dur-base: 240ms; --dur-slow: 420ms;
}

/* Everything non-essential stops when the reader asks for it. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ---------- dark theme ----------
   The reader chooses; the system only supplies the default.
   `data-theme` is written by the inline script in every page's <head>, before
   the first paint, so an explicit choice always wins over the OS setting.

   ⚠️ The dark tokens are written TWICE below and the two copies MUST stay
   identical. There is no build step to keep them in sync, so
   `tools/cek-kontras.py` compares them and fails if they drift. Edit one, edit
   the other, then run the script.

   Copy 1 — the reader picked dark explicitly. */
:root[data-theme="dark"] {
  color-scheme: dark;

  --brand: #ff9016;
  --brand-line: #ff9016;
  --brand-deep: #ffb366;
  --brand-soft: #3a2410;
  --cyan-soft: #0d2b33;
  --sun: #fcd34d;
  --sun-soft: #33280d;

  /* On dark, the soft tints are dark, so the bright colours ARE the readable
     text here (tools/cek-kontras.py). The ink tokens invert accordingly; only
     --on-bright stays put, because the fills it sits on stay bright in both
     themes. */
  --brand-ink: #ff9016;
  --cyan-ink: #22d3ee;
  --good-ink: #34d399;

  --bg: #14100e;
  --bg-alt: #1c1613;
  --card: #211a16;
  --ink: #f7f1eb;
  --ink-soft: #c2b6ac;
  --muted: #8f837a;
  --line: #362b24;
  --line-strong: #4a3a30;

  --good: #34d399;
  --good-soft: #0d2b22;
  --warn: #f87171;
  --warn-soft: #2e1414;
  --shadow-float: 0 2px 6px rgba(0, 0, 0, .4), 0 20px 44px -18px rgba(0, 0, 0, .8);
}

/* Copy 2 — no explicit choice recorded, so the OS decides. `:not([data-theme])`
   matters: without it this would override a reader who deliberately asked for
   light while their phone is in dark mode. It also covers the no-JavaScript
   case, where the attribute is never written at all. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    color-scheme: dark;

    --brand: #ff9016;
    --brand-line: #ff9016;
    --brand-deep: #ffb366;
    --brand-soft: #3a2410;
    --cyan-soft: #0d2b33;
    --sun: #fcd34d;
    --sun-soft: #33280d;

    --brand-ink: #ff9016;
    --cyan-ink: #22d3ee;
    --good-ink: #34d399;

    --bg: #14100e;
    --bg-alt: #1c1613;
    --card: #211a16;
    --ink: #f7f1eb;
    --ink-soft: #c2b6ac;
    --muted: #8f837a;
    --line: #362b24;
    --line-strong: #4a3a30;

    --good: #34d399;
    --good-soft: #0d2b22;
    --warn: #f87171;
    --warn-soft: #2e1414;
    --shadow-float: 0 2px 6px rgba(0, 0, 0, .4), 0 20px 44px -18px rgba(0, 0, 0, .8);
  }
}

/* ---------- theme switch ---------- */

.theme-switch {
  display: inline-flex; align-items: center; gap: 2px;
  border: 1px solid var(--line-strong); border-radius: 999px;
  padding: 2px; background: var(--card); margin-left: var(--sp-2);
}
.theme-switch button {
  border: 0; background: none; cursor: pointer; color: var(--muted);
  font: inherit; font-size: 13px; line-height: 1;
  padding: 6px 9px; border-radius: 999px;
  display: inline-grid; place-items: center;
  transition: background var(--dur-fast), color var(--dur-fast);
}
@media (hover: hover) and (pointer: fine) {
  .theme-switch button:hover { color: var(--ink); }
}
.theme-switch button[aria-pressed="true"] {
  background: var(--brand); color: var(--on-bright);
}
.theme-switch svg { width: 15px; height: 15px; display: block; }

* { box-sizing: border-box; }

/* ---------- phone hygiene (AUDIT_FRONTEND P1-8) ----------
   Hover rules sit in (hover: hover) and (pointer: fine): on a phone a tapped
   button otherwise keeps its hover colour until the next tap elsewhere. The
   grey tap flash is replaced by each control's own :active state. */
html { -webkit-tap-highlight-color: transparent; }
::placeholder { color: var(--muted); opacity: 1; }
@media (pointer: coarse) {
  /* iOS zooms the whole page into any field under 16 px. !important because
     pages style their own fields (.cari, inline <style>) with higher
     specificity, and none of them may go below this on a touch screen. */
  :where(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea) {
    font-size: max(16px, 1em) !important;
  }
  /* Touch targets: 40 px, not the 26 px a mouse pointer gets away with. */
  .qty > button:not(.btn-hapus), .pilih-swatch { min-width: 40px; min-height: 40px; }
  .viewer-bar .viewer-scale, .btn-hapus { min-height: 40px; }
  .theme-switch button { min-width: 36px; min-height: 36px; }
}
html { scroll-behavior: smooth; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font: 16px/1.6 var(--sans);
  -webkit-font-smoothing: antialiased;
  /* Kaki halaman menempel di dasar layar. Di halaman yang isinya lebih pendek
     dari layar (keranjang kosong, Custom sebelum berkas dijatuhkan, Saran,
     Testimoni) ia dulu mengambang di tengah, dengan pita kosong di bawahnya.
     Bagian TERAKHIR yang mengisi sisa tinggi; halaman yang lebih panjang dari
     layar tidak berubah satu piksel pun. */
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}
/* Isi halaman ada di <main> sejak 2026-10-23 (landmark + tautan lompat). Ia yang mengisi sisa
   tinggi, dan di dalamnya section terakhir, persis seperti dulu saat section anak langsung body. */
body > main { flex-grow: 1; display: flex; flex-direction: column; }
body > main > section:last-of-type { flex-grow: 1; }
/* Halaman admin (lokal, tanpa <main>) memakai style.css yang sama. */
body > section:last-of-type { flex-grow: 1; }
/* Lompatan ke #konten tidak perlu menggulir: isinya mulai tepat di bawah header yang menempel. */
#konten { scroll-margin-top: 50vh; }

/* ---------- typography ----------
   No webfont, deliberately: data costs money here and the system stack renders
   instantly. What follows is the shaping the stack does not do on its own. */

/* Promised in MYIMAGINATION_UI.md §1.3 but never actually shipped until now.
   Without it a heading regularly strands one word on its own last line, which
   reads as a mistake rather than a line break. */
h1, h2, h3, h4, .lead-big, .sec-sub {
  text-wrap: balance;
}

/* Tingkat judul mengikuti kerangka halaman (satu h1, tanpa loncatan), tampilannya tidak.
   `.h3` / `.h4` = "tampil seperti h3 / h4": nilai bawaan peramban untuk tag itu, lewat :where()
   supaya kekhususannya nol dan semua aturan lain (kelas, gaya sebaris) tetap menang. */
:where(.h3) { font-size: 1.17em; margin-block: 1em; }
:where(.h4) { font-size: 1em; margin-block: 1.33em; }

/* Terbaca pembaca layar, tidak terlihat. */
.hanya-pembaca {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}

/* Tautan lompat (WCAG 2.4.1): tersembunyi di atas layar, muncul saat difokus papan ketik. */
.lompat {
  position: absolute; left: 12px; top: 12px; z-index: 100;
  padding: 8px 14px; border-radius: var(--r-control);
  background: var(--ink); color: var(--bg); font-weight: 700; text-decoration: none;
  transform: translateY(-200%);
}
.lompat:focus { transform: none; }

/* Body copy gets `pretty` instead: it only rescues the last line, so it does
   not reflow whole paragraphs the way `balance` would. */
p, li, dd, figcaption, .card p, ol.steps p {
  text-wrap: pretty;
}

/* Reading measure. Prose past ~70 characters loses the line it is on; this
   site's audience reads it on a phone held one-handed. Grid and table cells are
   exempt because their width is already decided by the layout. */
.narrow p, .narrow li, .lead-big, p.lead {
  max-width: 68ch;
}

/* Every figure that can change while the reader is looking at it — the cart
   total counts up, quantities step, prices move when material cost changes.
   Proportional digits make those numbers visibly jitter mid-change; tabular
   ones hold their column. */
.price, .cart-count, .qty, table.spec td,
.total-row, .sum-val, .timeline .when, .order-row .code {
  font-variant-numeric: tabular-nums;
}

.wrap { max-width: 1140px; margin: 0 auto; padding: 0 20px; }
.narrow { max-width: 780px; }
a { color: var(--brand-deep); }

/* ---------- header ---------- */

header.site {
  border-bottom: 1px solid var(--line);
  background: color-mix(in srgb, var(--bg) 86%, transparent);
  backdrop-filter: blur(10px);
  position: sticky; top: 0; z-index: 50;
}
header.site .wrap { display: flex; align-items: center; gap: 22px; height: 64px; }
.brand {
  font-weight: 800; font-size: 18px; letter-spacing: -.03em;
  text-decoration: none; color: var(--ink); white-space: nowrap;
}
/* The wordmark was gradient-clipped text, which is the most recognisable
   generated-page signature of all and also the least legible at 18px. Flat
   accent, with the weight difference carrying the split instead. */
.brand span { color: var(--brand-ink); font-weight: 500; }
/* Satu baris, selalu. Dulu `flex-wrap: wrap` di dalam header setinggi 64 px:
   di layar sempit menunya turun ke baris kedua dan meluber KE LUAR header,
   menimpa isi halaman (25 px di 375 px; 45 px sesudah tautan Misteri masuk).
   Kini menu yang tidak muat bergulir mendatar di tempatnya, dan dua tepinya
   memudar supaya terlihat bahwa masih ada isinya. Di layar lebar tidak ada
   yang berubah: tak ada yang meluber, jadi tak ada yang bergulir. */
nav.main {
  display: flex; gap: 18px; margin-left: auto; align-items: center;
  flex-wrap: nowrap; min-width: 0; overflow-x: auto; overscroll-behavior-x: contain;
  scrollbar-width: none; padding-block: 8px;
}
nav.main::-webkit-scrollbar { display: none; }
@media (max-width: 899px) {
  header.site .wrap { gap: 14px; }
  nav.main {
    gap: 16px; padding-inline: 10px;
    -webkit-mask-image: linear-gradient(to right, transparent 0, #000 12px, #000 calc(100% - 12px), transparent 100%);
            mask-image: linear-gradient(to right, transparent 0, #000 12px, #000 calc(100% - 12px), transparent 100%);
  }
}
nav.main a {
  text-decoration: none; color: var(--ink-soft); font-size: 14.5px; font-weight: 500;
  white-space: nowrap; flex: none;
}
nav.main a[aria-current="page"] { color: var(--brand-ink); }
@media (hover: hover) and (pointer: fine) {
  nav.main a:hover { color: var(--brand-ink); }
}

/* The cart sits OUTSIDE the scrolling menu, beside it. Inside, it was the
   menu's last item: on a 375 px phone it and the theme switch after it were
   off the screen (x 701), so a buyer who had just added something saw no count
   and no way to the cart without swiping the menu. */
header.site .cart-link {
  text-decoration: none; color: var(--ink-soft); font-size: 14.5px; font-weight: 500;
  white-space: nowrap; flex: none;
}
header.site .cart-link[aria-current="page"] { color: var(--brand-ink); }
@media (hover: hover) and (pointer: fine) {
  header.site .cart-link:hover { color: var(--brand-ink); }
}
/* Phone and tablet: logo, cart, and theme on the first row; the menu gets the whole
   second row to itself and still scrolls if it does not fit. The fixed 64 px
   height is what made a wrapped menu spill over the page before, so it goes.
   Sampai 899 px, bukan 599 (2026-10-23): di 600-899 px menu berbagi baris dengan logo,
   keranjang, dan tombol tema, dan terpotong (768 px: ruang 377 px, isi 422 px). */
@media (max-width: 899px) {
  header.site .wrap {
    flex-wrap: wrap; height: auto; column-gap: 12px; row-gap: 0;
    padding-top: 12px;
  }
  header.site .cart-link { margin-left: auto; }
  nav.main { order: 3; flex: 1 0 100%; margin-left: 0; padding-block: 6px 10px; }
}
/* Ponsel: menu muat tanpa digeser. "Beranda" disembunyikan (logo sudah tautan ke beranda)
   dan "Lacak Pesanan" jadi "Lacak": isi menu 293 px, muat sampai layar 333 px. Di 375 px
   dulu 422 px di ruang 335 px dan "FAQ" seluruhnya di luar layar. Di bawah 333 px menu
   masih bisa digeser dan tepinya memudar. */
@media (max-width: 599px) {
  nav.main .nav-beranda, nav.main .nav-panjang { display: none; }
}

.cart-link { position: relative; display: inline-flex; align-items: center; gap: 6px; }
.cart-count {
  min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px;
  background: var(--brand); color: var(--on-bright);
  font-size: 12px; font-weight: 800; font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  display: inline-grid; place-items: center;
}
.cart-count[data-empty="true"] { background: var(--line-strong); color: var(--muted); }

/* ---------- hero ---------- */

.hero {
  padding: 60px 0 48px;
  position: relative; overflow: hidden; isolation: isolate;
}

/* The gradient drifts on a pseudo-element moved by transform, not by animating
   background-position. §4.1 of the UI design asked for background-position, but
   §5 forbids animating anything except transform and opacity — the budget wins,
   because background-position repaints the whole area every frame while a
   transform stays on the compositor. */
/* The drifting three-colour mesh is gone. It was the loudest of the four
   generated-page tells: a moving gradient wash behind a headline is the
   default output of every landing-page generator, and it earned nothing —
   §0 asks interactivity to reveal something, and this revealed nothing.
   What replaces it is a faint measuring grid, which at least says what kind
   of workshop this is. It does not move. */
.hero::before {
  content: ''; position: absolute; inset: 0; z-index: -1;
  background-image:
    linear-gradient(to right, var(--line) 1px, transparent 1px),
    linear-gradient(to bottom, var(--line) 1px, transparent 1px);
  background-size: 48px 48px;
  opacity: .5;
  mask-image: linear-gradient(to bottom, #000 0%, transparent 78%);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, transparent 78%);
}
@media (prefers-reduced-motion: reduce) {
  .hero::before { animation: none; transform: none; }
}

/* (The pulsing "Lihat layanan" scroll cue under the hero was removed 2026-10-30,
   AUDIT_FRONTEND P1-1.) */
.hero-grid { display: grid; gap: 34px; align-items: center; }
@media (min-width: 860px) { .hero-grid { grid-template-columns: 1.05fr 1fr; } }

.hero h1 {
  font-size: clamp(31px, 5.2vw, 50px); line-height: 1.06;
  letter-spacing: -.035em; margin: 0 0 16px; font-weight: 800;
}
/* Was a gradient clipped to the text. Gradient type is the single most
   recognisable generated-page signature, and it also weakens contrast at the
   pale end of the sweep. A flat accent with a rule under it says the same
   thing and stays readable. */
.hero h1 em {
  font-style: normal; color: var(--brand-ink);
  box-shadow: inset 0 -.14em 0 var(--brand-soft);
}
.hero p.lead { font-size: 18px; color: var(--ink-soft); margin: 0 0 26px; }

.eyebrow {
  display: inline-block; font-size: 12px; font-weight: 800;
  text-transform: uppercase; letter-spacing: .1em;
  color: var(--ink-soft); background: none;
  padding: 0 0 5px; border-bottom: 2px solid var(--brand);
  border-radius: 0; margin-bottom: 18px;
}

/* ---------- buttons ---------- */

.btn {
  display: inline-block; padding: 12px 22px; border-radius: var(--r-control);
  font-weight: 700; font-size: 15px; text-decoration: none;
  border: 1px solid transparent; cursor: pointer; font-family: inherit;
  transition: transform .12s ease, filter .12s ease;
}
.btn:active { transform: translateY(1px); }
.btn-primary {
  background: var(--brand); color: var(--on-bright);
}
@media (hover: hover) and (pointer: fine) {
  .btn-primary:hover { filter: brightness(1.07); }
}
.btn-ghost { border-color: var(--line-strong); color: var(--ink); background: var(--card); }
@media (hover: hover) and (pointer: fine) {
  .btn-ghost:hover { border-color: var(--brand-line); color: var(--brand-ink); }
}
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-row { display: flex; gap: 12px; flex-wrap: wrap; }
/* Phone: a row that wraps gives each button its row's width, so two calls to
   action stacked one under the other are one width (204 and 211 px before),
   and filters share one row in equal cells instead of breaking 2 + 1. */
@media (max-width: 599px) {
  .btn-row > .btn:not(.btn-sm) { flex: 1 1 auto; text-align: center; }
  .btn-row.filter-row { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 8px; }
  .btn-row.filter-row > .btn { padding: 10px 6px; font-size: 14px; }
}
.btn-sm { padding: 8px 14px; font-size: 13.5px; border-radius: var(--r-control); }
.btn-block { display: block; width: 100%; text-align: center; }

/* ---------- sections ---------- */

/* Section rhythm used to be one number for every section, which is why the page
   scrolled like a form. Space now follows what the section is doing: an opening
   breathes, a specification sits tight, a closing note sits tighter still. */
section { padding: var(--sp-8) 0 var(--sp-7); }
section.alt { background: var(--bg-alt); }
section.tight { padding: var(--sp-6) 0 var(--sp-5); }   /* dense, reference-like */
section.roomy { padding: 5.5rem 0 var(--sp-8); }        /* openings */
section + section.alt { border-top: 1px solid var(--line); }
h1.sec, h2.sec { font-size: clamp(23px, 3.2vw, 30px); letter-spacing: -.025em; margin: 0 0 8px; font-weight: 800; }
p.sec-sub { color: var(--ink-soft); margin: 0 0 30px; max-width: 64ch; }

/* ---------- cards & grid ---------- */

.grid { display: grid; gap: 22px; }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
@media (min-width: 700px) { .grid-2, .grid-3 { grid-template-columns: 1fr 1fr; } }
@media (min-width: 1000px) { .grid-3 { grid-template-columns: repeat(3, 1fr); } }

.card {
  background: var(--card); border: 1px solid var(--line-strong);
  border-radius: var(--radius); overflow: hidden;
  display: flex; flex-direction: column;
}

/* For blocks that are information, not choices. A card says "pick me"; a
   specification, a policy clause and a numbered step say no such thing, and
   boxing them was what turned every section into the same row of tiles.
   These get a rule and the page's own background instead. */
.sheet {
  background: none; border: 0; border-top: 2px solid var(--ink);
  border-radius: 0; padding: var(--sp-4) 0 0; margin: var(--sp-5) 0;
}
.sheet > * + * { margin-top: var(--sp-3); }
.sheet .card-body { padding: 0; }
.card-body { padding: 18px; display: flex; flex-direction: column; flex: 1; }
.card h3:where(:not(.h4)), .card h2.h3 { margin: 0 0 6px; font-size: 17.5px; letter-spacing: -.015em; font-weight: 700; }
.card p { margin: 0 0 14px; color: var(--ink-soft); font-size: 15px; flex: 1; }
/* ---------- catalogue card layout ----------
   The card had tags, a heading, a summary, a loose line of facts, a colour
   picker, a price and two buttons all stacked at the same rhythm, so nothing
   grouped and nothing led. These give it three bands: what it is, what it
   measures, what it costs. */
.kartu-kepala { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.card-body > .kartu-kepala + h3, .card-body > .kartu-kepala + h2.h3 { margin-top: 10px; }

.kartu-fakta {
  display: flex; gap: var(--sp-5); flex-wrap: wrap;
  margin: 0 0 var(--sp-4); padding: var(--sp-3) 0;
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
}
.kartu-fakta div { display: flex; flex-direction: column; gap: 1px; }
.kartu-fakta dt {
  font-size: 11px; letter-spacing: .05em; text-transform: uppercase;
  color: var(--muted); font-weight: 700;
}
.kartu-fakta dd {
  margin: 0; font-family: var(--mono); font-size: 13.5px;
  font-variant-numeric: tabular-nums; color: var(--ink);
}

/* .accent-top / .accent-cyan / .accent-violet: the 3px coloured strip on top of
   cards was template texture (AUDIT_FRONTEND P1-3) and is gone; the classes are
   left in the markup as harmless hooks. */

/* A small plain label, not an uppercase chip (AUDIT_FRONTEND P1-3). It takes
   the ink tokens, not the fills. .cyan / .violet were decoration only (which
   service line a card belongs to) and now read as the neutral grey label. */
.tag {
  display: inline-block; font-size: 12.5px; font-weight: 650;
  letter-spacing: 0; text-transform: none;
  padding: 2px 8px; border-radius: 6px;
  background: var(--brand-soft); color: var(--brand-ink);
}
.tag.cyan, .tag.violet { background: var(--bg-alt); color: var(--ink-soft); }
.tag.good { background: var(--good-soft); color: var(--good-ink); }
.tag.grey { background: var(--bg-alt); color: var(--ink-soft); }

/* ---------- 3D viewer ---------- */

.viewer {
  position: relative; width: 100%; aspect-ratio: 4 / 3;
  background: var(--bg-alt);
  border-bottom: 1px solid var(--line);
  /* pan-y, bukan none: usapan tegak menggulir halaman, usapan mendatar memutar
     benda (kanvasnya diberi nilai yang sama di viewer3d.js). */
  cursor: grab; touch-action: pan-y; overflow: hidden;
}
.viewer:active { cursor: grabbing; }
.viewer canvas { display: block; width: 100%; height: 100%; }
/* Kartu katalog: gambar diam model yang sama (tools/potret-produk.mjs, latar transparan, jadi
   latar .viewer di atas tetap yang terlihat), penampil 3D baru dibuat saat diketuk. Ia tidak
   ber-z-index: kilau pemuatan (::after) tetap tampil di atasnya selama model dimuat. */
.viewer[data-state="potret"] { cursor: pointer; }
.viewer-potret {
  position: absolute; inset: 0; display: block; width: 100%; height: 100%;
  padding: 0; border: 0; background: none; cursor: pointer;
  transition: opacity var(--dur-base) var(--ease-out);
}
.viewer-potret img { display: block; width: 100%; height: 100%; object-fit: contain; }
.viewer-potret.pergi { opacity: 0; pointer-events: none; }
.viewer-bar .viewer-putar::before { content: '↻'; margin-right: 5px; }

/* Fade the model in rather than letting it pop, and show a breathing skeleton
   while the context is still being built and the file parsed. */
.viewer .viewer-canvas { opacity: 0; transition: opacity var(--dur-base) var(--ease-out); }
.viewer[data-state="ready"] .viewer-canvas { opacity: 1; }
.viewer[data-state="loading"]::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(100deg,
    transparent 20%, color-mix(in srgb, var(--card) 70%, transparent) 46%, transparent 72%);
  background-size: 220% 100%;
  animation: sheen 1.5s linear infinite;
}
@keyframes sheen { to { background-position: -220% 0; } }

/* ---------- viewer bar ----------
   Nothing here sits on top of the canvas. Text over a rotating 3D scene is
   WCAG failure F83 -- the contrast behind each letter changes as the model
   turns, so it cannot be guaranteed at all. These read on a flat surface
   underneath the image instead, where the contrast is a known number. */
.viewer-bar {
  display: flex; align-items: flex-start; gap: var(--sp-3);
  flex-wrap: wrap;
  padding: var(--sp-2) 0 0;
  opacity: 0; animation: fadeIn var(--dur-slow) var(--ease-out) 120ms forwards;
}
@keyframes fadeIn { to { opacity: 1; } }

.viewer-bar .viewer-hint {
  font-size: 12px; color: var(--muted);
  display: inline-flex; align-items: center; gap: 5px;
  min-height: 26px;   /* one button's height: the hint reads level with the first one */
}
.viewer-bar .viewer-hint::before {
  content: '↻'; font-size: 13px; line-height: 1;
}
.viewer-bar .viewer-fallback {
  font-size: 11px; font-weight: 700; letter-spacing: .04em;
  color: var(--warn); margin-left: auto;
}

/* Scale reference toggle — answers "how big is it?" better than a number. */
/* Only the first control gets pushed right; the rest sit beside it, or every
   button would claim the free space and they would spread across the bar. */
.viewer-bar .viewer-scale + .viewer-scale { margin-left: 0; }
.viewer-bar .viewer-scale {
  margin-left: auto;
  font: 700 12px/1 var(--sans); letter-spacing: .01em;
  color: var(--ink); cursor: pointer;
  background: var(--card);
  border: 1px solid var(--line-strong); border-radius: var(--r-control);
  padding: 6px 10px;
  transition: border-color var(--dur-fast), color var(--dur-fast), transform var(--dur-fast);
}
@media (hover: hover) and (pointer: fine) {
  .viewer-bar .viewer-scale:hover { border-color: var(--brand-line); color: var(--brand-ink); }
}
.viewer-bar .viewer-scale:active { transform: scale(.95); }
.viewer-bar .viewer-scale[aria-pressed="true"] {
  background: var(--brand); border-color: var(--brand); color: var(--on-bright);
}

/* Terpasang / Tata letak cetak — two buttons that share one answer, so they
   are drawn as one control: joined edges, and the group (not each button) is
   what gets pushed to the right. Colours are the buttons' own, unchanged. */
/* Two columns. Left: the hint, and the Terpasang / Tata letak pair under it.
   Right: "Lihat di atas plate" over "Bandingkan ukuran", both the width of the
   wider, so they line up edge to edge on every product (they used to wrap
   apart on a model with the pair) and every card's bar is two rows high. */
.viewer-bar .viewer-kiri {
  display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
}
.viewer-bar .viewer-kiri .viewer-susunan { margin-left: 0; }
.viewer-bar .viewer-kiri .viewer-scale { white-space: nowrap; }
.viewer-bar .viewer-kontrol {
  display: flex; flex-direction: column; align-items: stretch; gap: 6px;
  margin-left: auto;
}
.viewer-bar .viewer-kontrol .viewer-scale { white-space: nowrap; }
.viewer-bar .viewer-kontrol > .viewer-scale { margin-left: 0; text-align: center; }
.viewer-bar .viewer-susunan { display: inline-flex; margin-left: auto; }
.viewer-bar .viewer-susunan .viewer-scale { margin-left: 0; }
.viewer-bar .viewer-susunan .viewer-scale:first-child {
  border-top-right-radius: 0; border-bottom-right-radius: 0;
}
.viewer-bar .viewer-susunan .viewer-scale:last-child {
  border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: -1px;
}
.viewer-bar .viewer-susunan .viewer-scale:focus-visible { position: relative; z-index: 1; }
.viewer-bar .viewer-susunan + .viewer-scale { margin-left: 0; }

.viewer.tall { aspect-ratio: 1 / 1; }
.viewer.wide { aspect-ratio: 16 / 10; }

/* ---------- price ---------- */

.price-row {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; margin-top: auto; padding-top: 14px; border-top: 1px solid var(--line);
}
.price { font-family: var(--mono); font-size: 22px; font-weight: 800; letter-spacing: -.03em; color: var(--brand-deep); }
.price-note { font-size: 12.5px; color: var(--muted); }

details.breakdown { margin-top: 12px; }
details.breakdown summary { cursor: pointer; font-size: 13px; color: var(--muted); font-weight: 600; }
details.breakdown table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 13.5px; }
details.breakdown td { padding: 5px 2px; border-bottom: 1px solid var(--line); }
details.breakdown td:last-child { text-align: right; font-family: var(--mono); }
details.breakdown tr:last-child td { border-bottom: none; font-weight: 700; }

/* ---------- notices ---------- */

/* One calm note style: a tinted panel, no thick left rule (AUDIT_FRONTEND
   P1-3 — the striped left border was template texture). The tint carries the
   meaning: orange = note, red = problem, green = done, cyan = info. */
.notice {
  border: 0; background: var(--brand-soft); border-radius: var(--radius-lg);
  padding: 14px 17px; font-size: 14.5px; margin: 16px 0;
}
.notice.danger { background: var(--warn-soft); }
.notice.good { background: var(--good-soft); }
.notice.cyan { background: var(--cyan-soft); }
.notice strong { color: var(--brand-deep); }
.notice.danger strong { color: var(--warn); }
.notice.good strong { color: var(--good-ink); }
.notice.cyan strong { color: var(--cyan-ink); }

.banner-draft {
  background: var(--warn-deep);
  color: #fff; padding: 9px 20px; font-size: 13.5px;
  text-align: center; font-weight: 700;
}

/* ---------- forms ---------- */

.field { margin-bottom: 15px; }
.field label { display: block; font-size: 14px; font-weight: 700; margin-bottom: 5px; }
.field .help { display: block; font-size: 13px; color: var(--muted); font-weight: 400; margin-top: 2px; }
/* Keterangan yang panjangnya berbeda-beda ditaruh DI BAWAH kolomnya, bukan di
   dalam labelnya: label sebaris membuat kolom-kolom yang bersebelahan mulai di
   garis yang sama, berapa pun panjang keterangan masing-masing. */
.field :is(input, select, textarea, output) + .help { margin-top: 6px; }
.field input, .field select, .field textarea {
  width: 100%; padding: 10px 12px; font: inherit; font-size: 16px;
  border: 1px solid var(--line-strong); border-radius: var(--radius);
  background: var(--bg); color: var(--ink);
}
.field input:focus, .field select:focus, .field textarea:focus {
  outline: 2px solid var(--brand-line); outline-offset: 1px; border-color: transparent;
}
.field input[type="number"] { font-family: var(--mono); text-align: right; }
.field textarea { min-height: 90px; resize: vertical; }
/* Galat SATU kolom, ditulis tepat di bawah kolomnya (nama dan nomor WhatsApp di
   keranjang). Kosong berarti tidak ada galat, dan tidak memakan tempat. Warnanya
   dijaga tools/cek-kontras.py: --warn di atas --card. */
.field .galat { display: block; font-size: 13px; font-weight: 600; color: var(--warn); margin-top: 5px; }
.field .galat:empty { display: none; }
.field input.salah { border-color: var(--warn); }

/* ---------- dropzone ---------- */

.dropzone {
  border: 2px dashed var(--line-strong); border-radius: var(--radius);
  padding: 42px 22px; text-align: center; background: var(--bg-alt);
  cursor: pointer; transition: border-color .15s ease, background .15s ease;
}
.dropzone.over { border-color: var(--brand-line); background: var(--brand-soft); }
@media (hover: hover) and (pointer: fine) {
  .dropzone:hover { border-color: var(--brand-line); background: var(--brand-soft); }
}
.dropzone .big { font-size: 17px; font-weight: 700; margin-bottom: 6px; }
.dropzone .small { font-size: 13.5px; color: var(--muted); }

/* ---------- spec table ---------- */

table.spec { width: 100%; border-collapse: collapse; font-size: 14.5px; }
table.spec th, table.spec td { padding: 9px 6px; border-bottom: 1px solid var(--line); text-align: right; }
table.spec th:first-child, table.spec td:first-child { text-align: left; }
table.spec th { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
/* Baris yang nilainya bisa lebih lebar dari kolomnya (tiga ukuran benda di
   Custom): satu sel selebar tabel, label di kiri dan nilai di kanan selagi
   muat sebaris. Kalau tidak muat, nilainya turun UTUH ke baris berikutnya dan
   tetap rata kanan - tidak patah di tengah "× 74.0 mm", dan tidak lagi memeras
   kolom label sampai label baris lain ikut patah. */
table.spec .spec-lebar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0 12px; }
table.spec .spec-lebar > :last-child { margin-left: auto; text-align: right; }
td.num, .num { font-family: var(--mono); }
tr.total td { border-top: 2px solid var(--ink); border-bottom: none; font-weight: 800; padding-top: 12px; }

/* ---------- cart ---------- */

.cart-item {
  display: grid; grid-template-columns: 72px 1fr auto; gap: 14px;
  align-items: center; padding: 15px 0; border-bottom: 1px solid var(--line);
}
.cart-item .thumb {
  width: 72px; height: 72px; border-radius: var(--radius);
  display: grid; place-items: center; font-size: 22px; font-weight: 800;
  color: var(--on-bright);
}
.cart-item h4, .cart-item h2 { margin: 0 0 3px; font-size: 15.5px; }
.cart-item .meta { font-size: 13px; color: var(--muted); }
/* Nama berkas unggahan bisa 95 karakter tanpa spasi. Tanpa ini lebar
   min-content kolom rincian = seluruh nama, dan kolom 1fr serta kisi
   halaman di atasnya ikut melebar: uji rusak 2026-10-23 membuat halaman 553 px
   di layar 360 px. `anywhere` (bukan break-word) juga mengecilkan min-content,
   jadi kisinya tidak melebar sejak awal. */
.cart-item > div { min-width: 0; }
.cart-item h2, .cart-item .meta, #namaFile { overflow-wrap: anywhere; }
/* Kolom harga. Angkanya tidak boleh patah: "Rp" di satu baris dan "81.000" di
   baris berikutnya terbaca sebagai dua hal. */
.cart-item .harga { text-align: right; }
.cart-item .harga .num { white-space: nowrap; }
/* Di layar sempit tiga kolom tidak muat — pada 375 px kolom harga tinggal 53 px
   dan itulah yang mematahkan angkanya. Harga turun ke barisnya sendiri di bawah
   rincian, sejajar dengan nama barangnya. */
@media (max-width: 520px) {
  .cart-item { grid-template-columns: 56px 1fr; align-items: start; }
  .cart-item .thumb { width: 56px; height: 56px; font-size: 19px; }
  .cart-item .harga {
    grid-column: 2; text-align: left;
    display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px;
  }
  .cart-item > .btn-hapus { grid-column: 2; justify-self: start; }
}
.qty { display: inline-flex; align-items: center; gap: 8px; margin-top: 6px; }
/* Hanya anak LANGSUNG, dan bukan tombol hapus. Tombol hapus duduk di dalam .qty
   juga, dan dulu kedua aturan di bawah bocor ke sana: lebarnya terkunci 26 px
   dan labelnya dipaksa minimal 22 px (min-width mengalahkan max-width: 0), jadi
   yang tampil adalah "Hap" yang terpotong dan meluber keluar dari tombolnya. */
.qty > button:not(.btn-hapus) {
  width: 26px; height: 26px; border-radius: var(--r-control); border: 1px solid var(--line-strong);
  background: var(--card); color: var(--ink); cursor: pointer; font-size: 15px; line-height: 1;
}
@media (hover: hover) and (pointer: fine) {
  .qty > button:not(.btn-hapus):hover { border-color: var(--brand-line); color: var(--brand-ink); }
}
.qty > span { font-family: var(--mono); min-width: 22px; text-align: center; font-weight: 700; }

/* Removing a row collapses it instead of making it vanish, so the eye can
   follow which line left. max-height is a layout property and §5 normally
   forbids animating those — allowed here as a deliberate exception: it runs
   once, on one element, on an explicit user action, and there is no
   compositor-only way to collapse a list row to zero height. */
.cart-item {
  overflow: hidden; max-height: 320px;
  transition: opacity var(--dur-base) var(--ease-out),
              transform var(--dur-base) var(--ease-out),
              max-height var(--dur-base) var(--ease-out),
              padding var(--dur-base) var(--ease-out);
}
.cart-item.removing {
  opacity: 0; transform: translateX(-18px);
  max-height: 0; padding-top: 0; padding-bottom: 0;
  border-bottom-color: transparent;
}

/* Totals tick to their new value rather than snapping, so a change in the
   summary is noticed when the eye is somewhere else on the page. */
.counting { font-variant-numeric: tabular-nums; }
/* Delete: an icon button that states its intent on hover, and turns red only
   when it is actually about to be pressed. */
.btn-hapus {
  display: inline-flex; align-items: center; gap: 6px;
  border: 1px solid var(--line-strong); background: var(--card); color: var(--muted);
  border-radius: var(--r-control); padding: 5px 10px; cursor: pointer;
  font: 600 12.5px/1 var(--sans); transition: all .14s ease;
}
.btn-hapus svg { width: 14px; height: 14px; flex: none; }
.btn-hapus .label { max-width: 0; overflow: hidden; white-space: nowrap; transition: max-width .18s ease; }
@media (hover: hover) and (pointer: fine) {
  .btn-hapus:hover {
    border-color: var(--warn); background: var(--warn-soft); color: var(--warn);
  }
  .btn-hapus:hover .label { max-width: 60px; }
}
.btn-hapus:active { transform: scale(.96); }

/* ---------- shipping picker ---------- */

.ship-option {
  display: flex; gap: 12px; align-items: flex-start;
  border: 1.5px solid var(--line); border-radius: var(--radius);
  padding: 12px 14px; margin-bottom: 10px; cursor: pointer;
  background: var(--card); transition: border-color .14s ease, background .14s ease;
}
@media (hover: hover) and (pointer: fine) {
  .ship-option:hover { border-color: var(--brand-line); }
}
.ship-option.selected { border-color: var(--brand-line); background: var(--brand-soft); }
/* Yang diredupkan hanya yang tidak bisa dipilih: nama, radio, tarif. Baris
   keterangannya TIDAK — pada pilihan yang belum tersedia, di situlah tertulis
   apa yang harus dilakukan supaya ia tersedia ("Pilih titik lokasi di peta…").
   Dulu seluruh labelnya opacity .55, dan kalimat itu jatuh ke kontras 2,05 : 1
   di tema terang: petunjuk yang paling perlu dibaca justru yang paling pudar. */
.ship-option.disabled { cursor: not-allowed; background: var(--bg-alt); }
.ship-option.disabled .name,
.ship-option.disabled .fee,
.ship-option.disabled input { opacity: .55; }
.ship-option input { margin-top: 3px; accent-color: var(--brand-line); flex: none; }
.ship-option .name { font-weight: 700; font-size: 14.5px; }
.ship-option .sub { font-size: 13px; color: var(--muted); margin-top: 2px; }
.ship-option .fee { margin-left: auto; font-family: var(--mono); font-weight: 800; white-space: nowrap; }
.ship-group-title {
  font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase;
  color: var(--muted); margin: 16px 0 8px;
}

/* ---------- map ---------- */

#peta { height: 300px; border-radius: var(--radius); border: 1px solid var(--line-strong); z-index: 1; }
.map-readout {
  display: flex; gap: 14px; flex-wrap: wrap; align-items: center;
  font-size: 13px; color: var(--muted); margin-top: 10px;
}
.map-readout .coord { font-family: var(--mono); color: var(--ink); }

/* ---------- order timeline ---------- */

.timeline { list-style: none; padding: 0; margin: 0; }
.timeline li {
  position: relative; padding: 0 0 20px 40px;
  border-left: 2px solid var(--line); margin-left: 13px;
}
.timeline li:last-child { border-left-color: transparent; padding-bottom: 0; }
.timeline li::before {
  content: ''; position: absolute; left: -9px; top: 2px;
  width: 16px; height: 16px; border-radius: 50%;
  background: var(--card); border: 2px solid var(--line-strong);
}
.timeline li.done { border-left-color: var(--good); }
.timeline li.done::before { background: var(--good); border-color: var(--good); }
.timeline li.now::before {
  background: var(--brand); border-color: var(--brand);
  box-shadow: 0 0 0 5px color-mix(in srgb, var(--brand) 25%, transparent);
}
.timeline li.cancelled::before { background: var(--warn); border-color: var(--warn); }
.timeline h4, .timeline h3.h4 { margin: 0 0 3px; font-size: 15.5px; }
.timeline li.now h4, .timeline li.now h3.h4 { color: var(--brand-deep); }
.timeline li:not(.done):not(.now) h4, .timeline li:not(.done):not(.now) h3.h4 { color: var(--muted); font-weight: 500; }
.timeline p { margin: 0; font-size: 14px; color: var(--ink-soft); }
.timeline .when { font-size: 12px; color: var(--muted); font-family: var(--mono); }

.code-badge {
  display: inline-block; font-family: var(--mono); font-size: 17px; font-weight: 800;
  letter-spacing: .04em; padding: 8px 14px; border-radius: var(--radius);
  background: var(--brand-soft); color: var(--brand-deep); border: 1px dashed var(--brand-line);
}

/* ---------- admin table ---------- */

.order-row {
  display: grid; gap: 12px; align-items: center;
  grid-template-columns: 1fr; padding: 14px 0; border-bottom: 1px solid var(--line);
}
@media (min-width: 800px) { .order-row { grid-template-columns: 190px 1fr 180px auto; } }
.order-row .code { font-family: var(--mono); font-weight: 800; font-size: 14px; }
.order-row select { padding: 7px 9px; border-radius: var(--r-control); border: 1px solid var(--line-strong);
  background: var(--bg); color: var(--ink); font: inherit; font-size: 13.5px; width: 100%; }
.pill {
  display: inline-block; font-size: 11px; font-weight: 800; letter-spacing: .05em;
  text-transform: uppercase; padding: 3px 9px; border-radius: 999px;
}
.pill.wait { background: var(--sun-soft); color: #a16207; }
.pill.work { background: var(--brand-soft); color: var(--brand-deep); }
.pill.ship { background: var(--cyan-soft); color: var(--cyan-ink); }
.pill.done { background: var(--good-soft); color: var(--good-ink); }
.pill.dead { background: var(--warn-soft); color: var(--warn); }

/* ---------- colour picker ---------- */

.color-choice { display: flex; gap: 8px; margin: 12px 0 0; }
.color-choice button {
  flex: 1; border: 1.5px solid var(--line); background: var(--card); color: var(--ink-soft);
  border-radius: var(--radius); padding: 9px 6px; cursor: pointer;
  font: 700 12.5px/1.35 var(--sans); text-align: center; transition: all .14s ease;
}
.color-choice button .sub { display: block; font-weight: 500; font-size: 11.5px; color: var(--muted); margin-top: 2px; }
@media (hover: hover) and (pointer: fine) {
  .color-choice button:hover { border-color: var(--brand-line); }
}
.color-choice button[aria-pressed="true"] {
  border-color: var(--brand-line); background: var(--brand-soft); color: var(--brand-deep);
}
.color-choice button[aria-pressed="true"] .sub { color: var(--brand-deep); }
.swatch-row { display: inline-flex; gap: 3px; vertical-align: -2px; margin-right: 5px; }
.swatch { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }

/* ---------- steps ---------- */

ol.steps { counter-reset: s; list-style: none; padding: 0; margin: 0; }
ol.steps li {
  counter-increment: s; position: relative;
  padding: 0 0 24px 46px; border-left: 2px solid var(--line); margin-left: 15px;
}
ol.steps li:last-child { border-left-color: transparent; padding-bottom: 0; }
ol.steps li::before {
  content: counter(s); position: absolute; left: -16px; top: -2px;
  width: 30px; height: 30px; border-radius: 50%;
  background: var(--brand);
  color: var(--on-bright); font-size: 13.5px; font-weight: 800;
  display: grid; place-items: center;
}
ol.steps h4, ol.steps h3.h4 { margin: 3px 0 4px; font-size: 16px; }
ol.steps p { margin: 0; color: var(--ink-soft); font-size: 15px; }

/* ---------- footer ---------- */

footer.site {
  border-top: 1px solid var(--line); margin-top: 44px;
  padding: 34px 0; color: var(--muted); font-size: 14px;
}
footer.site .wrap { display: flex; gap: 20px; flex-wrap: wrap; justify-content: space-between; }

/* ---------- procedural decoration ---------- */

.dekor {
  position: relative; width: 100%; aspect-ratio: 4 / 3;
  background:
    radial-gradient(120% 90% at 50% 8%, var(--brand-soft), transparent 62%),
    linear-gradient(165deg, var(--bg-alt), var(--card));
  border-radius: var(--radius); overflow: hidden;
}
.dekor canvas { display: block; }
.dekor.tall { aspect-ratio: 1 / 1; }
.dekor.small { aspect-ratio: 1 / 1; max-width: 190px; margin: 0 auto; }
.dekor.strip { aspect-ratio: 16 / 7; }
.dekor[data-state="ready"] canvas { animation: fadeIn var(--dur-slow) var(--ease-out) both; }
/* Sits BELOW the decoration, never on it — same F83 reason as the viewer bar.
   A gradient scrim over a moving canvas hides the failure, it does not fix it. */
.dekor-caption {
  display: block;
  padding: var(--sp-3) 18px var(--sp-4);
  font-size: 12.5px; line-height: 1.5; color: var(--muted);
}

/* ---------- card interaction ---------- */

.card { transition: transform var(--dur-base) var(--ease-out), box-shadow var(--dur-base) var(--ease-out); }
@media (hover: hover) and (pointer: fine) {
  .card:hover { border-color: var(--brand-line); }
}

:where(a, button, input, select, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--brand-line); outline-offset: 2px; border-radius: 6px;
}

/* Filtering hides cards instead of rebuilding the grid. Rewriting innerHTML
   destroyed and recreated every WebGL context, which flickered and churned
   contexts for no reason. */
.card.filtered-out { display: none; }
/* No `content-visibility: auto` here, deliberately. It skips layout for the
   subtree, which collapses the viewer's aspect-ratio box to a few pixels and
   leaves the IntersectionObserver that builds the WebGL context with nothing
   meaningful to observe — the viewers never load. Lazy context creation is the
   bigger win of the two, so this one goes. */
#katalog > .card { animation: cardIn var(--dur-base) var(--ease-out) both; }
@keyframes cardIn {
  from { opacity: 0; transform: translateY(8px) scale(.985); }
  to   { opacity: 1; transform: none; }
}

/* ---------- skeleton ---------- */

.skeleton {
  border-radius: var(--radius); border: 1px solid var(--line);
  background: var(--card); overflow: hidden;
}
.skeleton .sk-viewer { aspect-ratio: 4 / 3; background: var(--bg-alt); }
.skeleton .sk-body { padding: var(--sp-4); }
.skeleton .sk-line {
  height: 12px; border-radius: 6px; background: var(--bg-alt); margin-bottom: 10px;
}
.skeleton .sk-line.short { width: 45%; }
.skeleton .sk-line.mid { width: 75%; }
.skeleton { position: relative; }
.skeleton::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(100deg,
    transparent 20%, color-mix(in srgb, var(--line) 55%, transparent) 46%, transparent 72%);
  background-size: 220% 100%;
  animation: sheen 1.5s linear infinite;
}

/* ---------- toast ---------- */

#toasts {
  position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%);
  z-index: 200; display: flex; flex-direction: column; gap: 8px;
  align-items: center; pointer-events: none; width: min(92vw, 420px);
}
.toast {
  pointer-events: auto; width: 100%;
  background: var(--ink); color: var(--bg);
  border-radius: var(--radius); padding: 12px 16px;
  font-size: 14px; font-weight: 600;
  box-shadow: var(--shadow-float);
  display: flex; align-items: center; gap: 10px;
  animation: toastIn var(--dur-base) var(--ease-spring) both;
}
.toast.leaving { animation: toastOut var(--dur-fast) var(--ease-out) both; }
.toast a { color: inherit; text-decoration: underline; margin-left: auto; white-space: nowrap; }
.toast.good { background: var(--good); color: #04241a; }
@keyframes toastIn  { from { opacity: 0; transform: translateY(16px) scale(.96); } }
@keyframes toastOut { to   { opacity: 0; transform: translateY(8px) scale(.98); } }

/* ---------- cart badge pulse ---------- */

.cart-count { transition: transform var(--dur-fast) var(--ease-spring); }
.cart-count.bump { animation: bump 420ms var(--ease-spring); }
@keyframes bump {
  0% { transform: scale(1); }
  38% { transform: scale(1.45); }
  100% { transform: scale(1); }
}

/* ---------- sticky buy bar (mobile) ---------- */

.buybar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 90;
  background: color-mix(in srgb, var(--card) 94%, transparent);
  backdrop-filter: blur(10px);
  border-top: 1px solid var(--line);
  padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
  display: flex; align-items: center; gap: 12px;
  transform: translateY(110%);
  transition: transform var(--dur-base) var(--ease-out);
}
.buybar.show { transform: none; }
.buybar .price { font-size: 19px; }
.buybar .btn { margin-left: auto; }
@media (min-width: 900px) { .buybar { display: none; } }

/* ---------- utility ---------- */

.mono { font-family: var(--mono); }
.muted { color: var(--muted); }
.center { text-align: center; }

/* Utility classes replacing the inline styles that were repeated most often
   across pages. Each one existed 5+ times as a style="" attribute. */
.txt-sm { font-size: 13.5px; }
.txt-xs { font-size: 12.5px; }
.txt-body { color: var(--ink-soft); font-size: 15px; }
.flush { margin: 0; }
.mt-0 { margin-top: 0; }
.mt-2 { margin-top: var(--sp-2); }
.mt-4 { margin-top: var(--sp-4); }
.mt-5 { margin-top: var(--sp-5); }
.mt-6 { margin-top: var(--sp-6); }
.mb-2 { margin-bottom: var(--sp-2); }
.mb-4 { margin-bottom: var(--sp-4); }
.mb-5 { margin-bottom: var(--sp-5); }
.plain-link { text-decoration: none; color: inherit; }
.grow { flex: 1; }
.stack > * + * { margin-top: 14px; }
.hidden { display: none !important; }
.spinner {
  width: 18px; height: 18px; border-radius: 50%; display: inline-block;
  border: 2px solid var(--line-strong); border-top-color: var(--brand-line);
  animation: spin .7s linear infinite; vertical-align: -3px;
}
@keyframes spin { to { transform: rotate(360deg); } }
