/* profile.af — design system layered on top of Tailwind.
   Editorial SaaS: white canvas, charcoal ink, neutral UI, colour only where
   it carries meaning. The sky→violet→pink gradient is a BRAND MOMENT only
   (final CTA, hero detail, logo, premium highlight) — never a default fill.

   Colour comes from the logo and nowhere else. Two ramps, and the difference
   between them is the whole rule: --pf-brand-* are the logo's literal stops
   and are decorative; --pf-primary/-accent/-secondary are those same hues
   walked down to AA and are the only ones allowed under text. Semantic
   success/warning/danger stay off-brand on purpose.

   ── Token contract ───────────────────────────────────────────────────────
   --pf-ink*      platform action colour (charcoal). Chrome buttons ONLY.
   --pf-primary   the *themeable* accent. head.php re-declares it per page:
                  platform chrome  → brand violet (default)
                  public profile   → that profile's saved colour
                  Never bind chrome buttons to it, or a user's pink profile
                  would repaint the dashboard. Links/focus/data-viz may use it.
   --pf-btn-*     resolved button surface. :root = ink; theme bodies remap it
                  back to --pf-primary so profiles keep their personality. */

:root{
  /* Canvas & surfaces */
  --pf-bg:#FFFFFF;
  --pf-surface:#FFFFFF;
  --pf-surface-soft:#F7F8FA;
  --pf-surface-strong:#F1F3F6;
  --pf-border:#E5E7EB;
  --pf-border-strong:#D4D8DF;

  /* Type */
  --pf-text:#111318;
  --pf-muted:#5F6673;      /* text-secondary */
  /* DECORATIVE ONLY — 3.2:1 on white. Clears the 3:1 non-text bar (icons,
     separators, placeholder glyphs, chart gridlines) but FAILS WCAG AA 4.5:1
     for body text. Never colour readable copy with it; use --pf-muted (5.9:1). */
  --pf-muted-2:#8B919C;

  /* Action (platform chrome) */
  --pf-ink:#17191E;
  --pf-ink-hover:#000000;
  --pf-ink-ring:rgba(17,19,24,.16);

  /* ── BRAND COLOURS — THE SINGLE SOURCE OF TRUTH ─────────────────────────
     The four logo colours, exact. Nothing else in this file, in
     tailwind.config.js or in any view may declare a brand colour: every
     branded surface in the product resolves to one of these four or to
     --pf-grad below. Do not add a fifth, and do not adjust these values —
     they are the logo. */
  --pf-brand-cyan:#49CCF9;
  --pf-brand-violet:#7B68EE;
  --pf-brand-pink:#FD71AF;
  --pf-brand-yellow:#FFC800;

  /* Primary = the logo's violet, unmodified. It is the hinge of the gradient
     and the one stop that works as an interface colour, so it drives links,
     primary buttons, focus rings, active nav, tabs, switches, checkboxes,
     radios, sliders, progress bars, selected states and chart series. */
  --pf-primary:var(--pf-brand-violet);   /* themeable; see contract above */
  --pf-accent:var(--pf-brand-pink);
  /* The same two colours as bare channels, so a tint can be written
     `rgb(var(--pf-primary-rgb) / 12%)`. NOT a second source of truth: every
     runtime emitter writes the hex and the triplet together from one hex, via
     pf_color_vars() in helpers.php. These are the defaults for pages that
     never emit either — the auth screens, the dashboard, the maintenance
     page. See the note on pf_color_vars for why the tints stopped using
     color-mix(…, transparent). */
  --pf-brand-violet-rgb:123 104 238;
  --pf-brand-pink-rgb:253 113 175;
  --pf-primary-rgb:var(--pf-brand-violet-rgb);
  --pf-accent-rgb:var(--pf-brand-pink-rgb);
  --pf-secondary:var(--pf-brand-cyan);
  --pf-dark:#111318;

  /* ── RENDERINGS OF THOSE FOUR — not new brand colours ───────────────────
     The four values above are 1.55:1 to 4.15:1 against white, so none of them
     can legally carry 14px text or sit under white text. Each `-ink` below is
     its own hue darkened by the MINIMUM needed to reach 4.5:1 on white, on
     --pf-surface-soft and on its own tint — violet only moves 11%, so a link
     still reads as the logo's violet. Text-bearing surfaces use these; every
     decorative surface (gradient, glow, wash, chart fill, slider track,
     progress bar, illustration accent) uses the exact value.
     Each `-soft`/`-border`/`-ring` is the same hue diluted into white, so a
     tinted card is visibly the same colour as the button beside it. */
  --pf-primary-ink:#6D5CD3;    /* 5.09 white / 4.79 soft — links, small labels, button fill */
  --pf-primary-hover:#5F4FC0;  /* one step further, for hover/active */
  --pf-primary-soft:#F7F6FE;
  --pf-primary-soft-2:#F2F0FD;
  --pf-primary-border:#DFDBFB;

  --pf-cyan-ink:#2C7A95;       /* 4.86 / 4.57 */
  --pf-cyan-soft-2:#EDFAFE;

  --pf-pink-ink:#B04F7A;       /* 4.95 / 4.65 */
  --pf-pink-soft-2:#FFF1F7;

  --pf-yellow-ink:#8C6E00;     /* 4.83 / 4.55 */
  --pf-yellow-soft-2:#FFFAE6;

  /* Semantic — deliberately NOT rebranded. These carry meaning, not identity;
     a green that drifts toward the brand stops reading as "success". All three
     re-verified at AA on white (5.02 / 5.02 / 4.83). */
  --pf-success:#15803D;
  --pf-warning:#B45309;
  --pf-danger:#DC2626;
  --pf-info:var(--pf-brand-violet);

  /* Shape & motion */
  --pf-radius-ctl:10px;    /* buttons, inputs */
  --pf-radius:14px;        /* cards */
  --pf-radius-lg:16px;
  --pf-radius-media:22px;  /* major media blocks */
  --pf-ring:rgba(123,104,238,.22);
  --pf-shadow-sm:0 1px 2px rgba(17,19,24,.05);
  --pf-shadow-md:0 8px 24px -12px rgba(17,19,24,.16);
  --pf-shadow-lg:0 24px 60px -24px rgba(17,19,24,.28);

  /* ── Motion ─────────────────────────────────────────────────────────────
     Three durations and two curves for the whole interaction layer. Before
     these existed the file carried thirteen different durations between 60ms
     and 500ms, so two controls that do the same kind of thing moved at
     different speeds depending on when they were written.

       --pf-dur-1  state flips in place — hover, focus ring, colour, tick
       --pf-dur-2  a surface appearing — dropdown, tooltip, popover, tab body
       --pf-dur-3  something crossing distance — drawer, modal, panel

     Scale rule: the further a thing travels the longer it may take, and
     nothing in the product chrome exceeds --pf-dur-3. Anything slower reads
     as lag rather than polish.

       --pf-ease     enter/settle — decelerates into place (the default)
       --pf-ease-io  moving between two known states, e.g. a drawer sliding

     Deliberately NOT tokenised: the landing page's theme-rotator crossfades
     (.pf-pv-*, 500ms). Those are ambient demonstration, not interaction, and
     interaction speed would destroy the effect.

     prefers-reduced-motion is handled once, globally, further down this file
     (it flattens every duration to .001ms), so a component never needs its
     own reduced-motion branch just to be safe. */
  --pf-dur-1:150ms;
  --pf-dur-2:180ms;
  --pf-dur-3:240ms;
  --pf-ease:cubic-bezier(.2,.7,.2,1);
  --pf-ease-io:cubic-bezier(.4,0,.2,1);

  /* Resolved button surface (chrome). Charcoal, NOT the brand: a dashboard
     full of violet buttons stops reading as a neutral workspace, and the
     brand loses its meaning when every action carries it. The brand button
     is .pf-btn-brand, applied by hand to marketing CTAs only.
     Theme bodies remap these three to the profile's colour further down. */
  --pf-btn-bg:var(--pf-ink);
  --pf-btn-bg-hover:var(--pf-ink-hover);
  --pf-btn-ring:var(--pf-ink-ring);

  /* Type stack — platform chrome only. Public-profile themes re-declare
     font-family on their own body class, so a user's serif/mono theme is never
     overwritten by the platform font. */
  --pf-font:"Plus Jakarta Sans", Inter, ui-sans-serif, system-ui, -apple-system,
            "Segoe UI", Roboto, "Helvetica Neue", sans-serif;

  /* ── THE BRAND GRADIENT — ONE TOKEN, ONE DIRECTION ──────────────────────
     Cyan → violet → pink → yellow, 100deg, at the official stops. This is the
     only gradient in the product. Nothing may declare its own: no page-
     specific sweep, no reversed copy, no two-stop shortcut. If a surface needs
     the brand gradient it uses --pf-grad, and if it needs it quieter it uses
     one of the three derivatives below — all of which keep the same four hues
     in the same order, so they read as one system rather than as a rainbow. */
  --pf-grad:linear-gradient(100deg,#49CCF9 0%,#7B68EE 35%,#FD71AF 68%,#FFC800 100%);

  /* Derivatives. Same hues, same order, same angle — only the intensity moves.

       --pf-grad-soft     the wash: 6% of the ramp over white. Ambient glows,
                          auth canvas, hero backdrops, empty/loading states.
       --pf-grad-preview  in-page product mocks, which must not out-shout the
                          page they sit inside.
       --pf-grad-ink      the only one that carries type, and the ONE place the
                          ramp is not all four colours. Why: the bare ramp runs
                          1.55:1 to 4.15:1 on white, so it has to be darkened
                          before white text can sit on it or before it can be
                          used as text — and a darkened yellow is brown. There
                          is no value that is both recognisably #FFC800 and
                          legible; #FFC800 at 4.5:1 is #8C6E00, olive. Rather
                          than ship a muddy fourth stop, type-bearing surfaces
                          run cyan → violet → pink at their -ink values: same
                          ramp, same direction, stopped one stop early. The
                          yellow keeps its full strength everywhere it belongs
                          — the wash, the glows, progress bars, chart series,
                          the mark itself. */
  --pf-grad-soft:linear-gradient(100deg,#F4FCFF 0%,#F7F6FE 35%,#FFF6FA 68%,#FFFCF0 100%);
  --pf-grad-preview:linear-gradient(100deg,#A9E2F8 0%,#B3A9EE 35%,#F3B4CE 68%,#FFDF8F 100%);
  --pf-grad-ink:linear-gradient(100deg,#2C7A95 0%,#6D5CD3 48%,#B04F7A 100%);

  /* Dark spotlight: deep charcoal base + soft ambient glows. On a near-black
     ground the EXACT brand values are what read, and nothing on top of them is
     text, so these are the four logo colours at low alpha — no substitutes.
     Kept wide and faint: three large soft radials rather than one saturated
     wash is the difference between premium and neon. */
  --pf-dark-base:#101216;
  --pf-glow-violet:rgba(123,104,238,.22);   /* #7B68EE */
  --pf-glow-cyan:rgba(73,204,249,.16);      /* #49CCF9 */
  --pf-glow-pink:rgba(253,113,175,.13);     /* #FD71AF */
  --pf-glow-yellow:rgba(255,200,0,.10);     /* #FFC800 */
}

/* Platform chrome font — scoped to NON-theme bodies only.
   Deliberately NOT applied via Tailwind's `font-sans` (which stays Inter) nor
   via html/inherit: a `body[class*="theme-"]` counter-rule would be 0-1-1 and
   would out-specify `.theme-academic{font-family:Georgia}` (0-1-0), silently
   stealing every theme's typography. Selecting the chrome instead means a
   public profile is never touched at all. */
body:not([class*="theme-"]){ font-family:var(--pf-font); }

/* Public profiles keep their own personality: the action colour falls back to
   the profile's --pf-primary (which head.php/profile_shell sets per user). */
body[class*="theme-"]{
  --pf-btn-bg:var(--pf-primary);
  --pf-btn-bg-hover:var(--pf-primary);
  --pf-btn-ring:var(--pf-ring);
}

*{ -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility; }
/* scroll-padding keeps focused/anchored fields clear of the sticky save bars
   (profile + theme pages) so a sticky action row never covers the input the
   user is typing in. */
html{ scroll-behavior:smooth; scroll-padding-bottom:110px; scroll-padding-top:84px; }
body{ font-feature-settings:"cv11","ss01"; }

/* Responsive safety net — prevents horizontal scroll from oversized media or
   long unbreakable strings (emails, URLs, single long names). Element-selector
   specificity stays below Tailwind utilities, so sized images are unaffected. */
img,svg,video{ max-width:100%; }

/* User images must never be stretched or squashed. `cover` + centred focal point
   means anything the layout forces into a fixed box is CROPPED, not distorted —
   and it is a no-op for images the layout leaves at their natural size (the box
   already matches the intrinsic ratio), so this is safe as a global default.
   Opt out with `object-contain` where the whole image must be visible. */
img{ object-fit:cover; object-position:center; }
img.object-contain{ object-fit:contain; }

/* A <picture> IS SOURCE SELECTION, NOT A LAYOUT BOX.
   ------------------------------------------------------------------
   responsive_img() wraps its <img> in a <picture> whenever the upload has
   WebP derivatives, and drops the wrapper when it does not (a placeholder, an
   SVG, a format Derive cannot read). Every one of its 32 call sites puts the
   sizing on the <img> — `w-14 h-14 shrink-0` on Explore, `w-full h-full
   object-cover` in the themes — because that is the element they can see.

   In normal flow the wrapper is inline, owns nothing, and that is harmless.
   In a FLEX ROW it is not: the wrapper becomes the flex item, so it arrives
   with `flex-shrink:1` and `min-width:auto` while the `shrink-0` meant to
   protect it sits on a child that is no longer an item — and the `max-width:
   100%` two rules above then drags the image down with the wrapper.

   Measured on /explore before this rule: a 56x56 avatar rendered **27x56**
   beside a long unbroken name, while the card next to it — same markup, same
   size, but a profile whose photo has NO derivatives and therefore no wrapper
   — stayed 56x56. Two cards on one page, disagreeing, because of a difference
   in the stored image rather than anything the layout asked for.

   `display:contents` removes the wrapper's box, which is what every call site
   already assumed it did. The <source> children are `display:none` in the UA
   sheet and are unaffected, and <picture> carries no semantics, so nothing is
   removed from the accessibility tree. `.pf-gtile picture` further down wants
   a real box and is more specific, so it still wins. */
picture{ display:contents; }

/* ---- Cover / banner images ------------------------------------------
   ONE predictable frame for every profile banner: 16:3, matching the ratio new
   uploads are center-cropped to (Upload::SPECS). Older/legacy covers with a
   different intrinsic ratio still land here safely — object-fit crops them
   symmetrically instead of squashing. Phones get a taller 5:2 frame, because a
   16:3 strip is only ~70px tall on a 375px screen.

   NEVER size a cover with a fixed height + w-full: that fixes BOTH axes to an
   arbitrary ratio and forces the browser to crop (or, without object-fit,
   stretch) unpredictably. aspect-ratio fixes one axis and derives the other.

   --pf-cover-pos is the focal-point hook: set it inline (e.g. "center 30%") to
   bias the crop; it safely defaults to centre.

   THE FRAME SIZES THE BANNER ON A LAPTOP — NOT THE VIEWPORT.
   `width:100%` + `aspect-ratio` derives the height from whatever box the image
   sits in, so a full-bleed banner's height is a FRACTION OF THE VIEWPORT: 270px
   at 1440, 360px at 1920, and it keeps growing. Above a 768px content column
   that reads as a page-wide photograph with a profile underneath it rather than
   as a profile header, and it upscales a file stored at 1600x300
   (Upload::SPECS). `.pf-coverframe` bounds the image to the TEMPLATE'S OWN
   content column from 640px up, so the banner is a header element the avatar,
   name and actions sit against — the shape every professional profile uses.

   --pf-cover-w is that column, and it is the only thing a template overrides:
   a wider layout passes its own width and gets the same treatment at its own
   scale, which is what keeps this ONE rule instead of one per theme. The
   phone case is deliberately untouched: below 640px the banner stays
   full-bleed and square-cornered, because inset margins on a 390px screen
   spend the width that makes the image readable at all. */
.pf-coverframe{ margin-inline:auto; }
@media (min-width:640px){
  /* --pf-cover-bleed IS THE SPACE THE AVATAR SITS IN.
     Sized to the column alone, the banner and the avatar shared one left edge —
     technically aligned and visually cramped, because a banner that starts
     exactly where its avatar starts reads as a backdrop that ran out of room
     rather than as a header the avatar is placed on. The banner is therefore
     wider than the column it heads by this much on each side, which is the
     inset every professional profile header gives its photo. It is only ever
     an inset: `min(100%, …)` means that when the viewport is the constraint
     the banner simply stops bleeding rather than pushing the page wider. */
  .pf-coverframe{ box-sizing:border-box;
    width:min(100%, calc(var(--pf-cover-w, 48rem) + 2 * var(--pf-cover-bleed, 1.5rem)));
    padding:var(--pf-cover-top, 1.5rem) var(--pf-cover-gutter, 1.25rem) 0; }
}
.pf-cover{ display:block; width:100%; aspect-ratio:5/2;
  object-fit:cover; object-position:var(--pf-cover-pos, center);
  background:var(--pf-surface-strong); }
@media (min-width:640px){
  /* max-height is a guard, not the mechanism: the width bound already caps the
     height, and this only stops a very wide --pf-cover-w from reintroducing a
     band. object-fit keeps the crop symmetrical, never a squash. */
  .pf-cover{ aspect-ratio:16/3; max-height:var(--pf-cover-max, 240px); }
  /* Descendant, not child: responsive_img() wraps the <img> in a <picture>
     (which is display:contents, so it has no box of its own). */
  .pf-coverframe .pf-cover{ border-radius:var(--pf-radius-media);
    box-shadow:0 0 0 1px rgba(17,19,24,.06), var(--pf-shadow-sm); }
}

/* Project thumbnails: stored 16:9, so frame them at 16:9 — same reasoning. */
.pf-thumb{ display:block; width:100%; aspect-ratio:16/9;
  object-fit:cover; object-position:var(--pf-cover-pos, center);
  background:var(--pf-surface-strong); }
/* `anywhere` (not break-word) so unbreakable words also shrink an element's
   min-content size — otherwise one long token widens a grid/flex column past
   the viewport and drags the whole layout off-screen (clipped, not scrolled,
   because of the overflow-x:clip guard below). Headings alone are not enough:
   theme heroes render headlines/taglines as <p> and emails/URLs as <a>. */
h1,h2,h3,h4,p,a,li,dd,dt,blockquote,figcaption,cite{ overflow-wrap:anywhere; }
/* Public profile pages render arbitrary user content (bios with raw URLs,
   long brand names) inside plain <div>s too — e.g. the pre-line About card.
   Inheritance carries this to every descendant, so one long token can never
   size a theme grid/flex track past the viewport. Scoped to theme bodies:
   dashboard/admin tables keep their scroll-inside-card behavior. */
body[class*="theme-"] div{ overflow-wrap:anywhere; }
/* Hard stop for horizontal page scroll on every layout. `clip` (unlike
   `hidden`) does not create a scroll container, so position:sticky keeps
   working. Individual wide widgets scroll inside their own overflow-x-auto. */
html, body{ overflow-x: clip; }
/* Grid/flex children may never force their track wider than the viewport —
   wide content (tables etc.) scrolls inside its own wrapper instead. */
.pf-card{ min-width:0; }

/* The hidden attribute must always win — Tailwind display utilities (.flex
   etc.) otherwise out-cascade the UA/preflight [hidden] rule (e.g. bulk bars). */
[hidden]{ display:none !important; }

/* Focus visibility for accessibility — one ring everywhere, always visible
   against both the white canvas and the dark spotlight sections. */
a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,
select:focus-visible,summary:focus-visible,[tabindex]:focus-visible{
  outline:2px solid var(--pf-primary); outline-offset:2px; border-radius:6px;
}
.pf-on-dark a:focus-visible,.pf-on-dark button:focus-visible{ outline-color:#fff; }

/* Motion: honour the OS setting globally rather than per-component. */
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{ animation-duration:.001ms !important; animation-iteration-count:1 !important;
    transition-duration:.001ms !important; scroll-behavior:auto !important; }
}

/* ---- Buttons --------------------------------------------------------
   Compact, confident, dark. Every variant carries the same geometry so a
   row of mixed buttons always lines up. Minimum 40px tall (44px on touch,
   see the touch-target rule below). */
.pf-btn{ display:inline-flex; align-items:center; justify-content:center; gap:.5rem;
  font-weight:600; font-size:.875rem; line-height:1.25rem; letter-spacing:-.005em;
  border-radius:var(--pf-radius-ctl); padding:.55rem 1rem; min-height:2.5rem;
  border:1px solid transparent; cursor:pointer; white-space:nowrap;
  transition:background-color var(--pf-dur-1) var(--pf-ease), border-color var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease),
             box-shadow var(--pf-dur-1) var(--pf-ease), transform var(--pf-dur-1) var(--pf-ease); }
.pf-btn:active{ transform:translateY(.5px); }

/* A BUTTON'S ICON IS NEVER THE THING THAT GIVES WAY.
   An <svg> is a flex item like any other, so it inherits `flex-shrink:1` and a
   narrow button resolves its overflow by squashing the glyph rather than
   letting the label ellipsize — a 16px icon silently becomes 11px, and only on
   the buttons whose labels are longest. The label is `white-space:nowrap`, so
   the icon is the ONLY item with any give: without this rule it always loses.
   One declaration on the button system, not a fix per theme. */
.pf-btn > svg{ flex:0 0 auto; }

.pf-btn-primary{ background:var(--pf-btn-bg); color:#fff; box-shadow:var(--pf-shadow-sm); }
.pf-btn-primary:hover{ background:var(--pf-btn-bg-hover); box-shadow:0 4px 14px -4px var(--pf-btn-ring); }

/* Secondary — a REAL button at rest. It carries a visible surface + border with
   no hover required: an action must never depend on hover to reveal that it is
   clickable (that also leaves it invisible on touch, where there is no hover). */
.pf-btn-ghost{ background:var(--pf-surface-soft); color:var(--pf-text);
  border-color:var(--pf-border-strong); }
.pf-btn-ghost:hover{ background:var(--pf-surface-strong); border-color:#C2C7D0; }
.pf-btn-ghost:active{ background:#E7EAEF; }

/* Quiet tertiary — no border. ONLY for genuinely low-stakes inline actions
   (dropdown items, "Wrong email?"). Never for a section-level CTA. */
.pf-btn-subtle{ background:transparent; color:var(--pf-muted); }
.pf-btn-subtle:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }

.pf-btn-danger{ background:#FEF2F2; color:#B91C1C; border-color:#FECACA; }
.pf-btn-danger:hover{ background:#FEE2E2; border-color:#FCA5A5; }
.pf-btn-danger-solid{ background:var(--pf-danger); color:#fff; }
.pf-btn-danger-solid:hover{ background:#B91C1C; }

.pf-btn-sm{ padding:.35rem .7rem; min-height:2rem; font-size:.8rem; border-radius:8px; gap:.35rem; }
.pf-btn-lg{ padding:.75rem 1.4rem; min-height:3rem; font-size:.95rem; border-radius:12px; }
.pf-btn-icon{ padding:.55rem; width:2.5rem; }
.pf-btn-icon.pf-btn-sm{ padding:.35rem; width:2rem; }

/* Disabled + loading — a button in flight must not be clickable twice. */
.pf-btn:disabled,.pf-btn[disabled],.pf-btn[aria-disabled="true"]{
  opacity:.5; cursor:not-allowed; transform:none; box-shadow:none; pointer-events:none; }
.pf-btn.is-loading{ position:relative; color:transparent !important; pointer-events:none; }
.pf-btn.is-loading > *{ visibility:hidden; }
.pf-btn.is-loading::after{ content:""; position:absolute; width:1rem; height:1rem;
  border:2px solid currentColor; border-radius:50%; border-top-color:transparent;
  color:#fff; animation:pf-spin .6s linear infinite; }
.pf-btn-ghost.is-loading::after,.pf-btn-subtle.is-loading::after{ color:var(--pf-muted); }

/* Touch targets live in ONE block near the END of this file — see
   "TOUCH TARGET LAYER". They must come after every component's own sizing
   declaration or those later, equal-specificity rules win on source order
   (media queries add no specificity). */

/* Icon-only action button (admin table rows: view / edit / suspend / delete).
   Replaces the ad-hoc `p-1.5 rounded-lg hover:bg-*` clusters that were copied
   across every admin list — one hit box, one hover language, one place to
   grow the target on touch. */
.pf-icon-btn{ display:inline-grid; place-items:center; width:2rem; height:2rem; flex:0 0 auto;
  border-radius:8px; color:var(--pf-muted); background:transparent;
  transition:background-color var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease); }
.pf-icon-btn:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }
.pf-icon-btn-success:hover{ background:#F0FDF4; color:var(--pf-success); }
.pf-icon-btn-warning:hover{ background:#FFFBEB; color:var(--pf-warning); }
.pf-icon-btn-danger:hover { background:#FEF2F2; color:var(--pf-danger); }

/* Avatar menu trigger — the hit box grows on touch (see the touch layer) while
   the avatar chip inside keeps its 36px visual size. */
.pf-avatar-btn{ display:grid; place-items:center; border-radius:999px; }
.pf-avatar-btn:hover{ background:var(--pf-surface-strong); }

/* ---- Shared media-upload field (media_field + media_gallery) ----------
   The preview IS the drop zone / click target. One visual language for every
   upload: avatar, cover, project cover, CV, and gallery tiles. */
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
  clip:rect(0,0,0,0); white-space:nowrap; border:0; }

.pf-media-drop{ position:relative; overflow:hidden; background:#f8fafc;
  border:1.5px dashed #d3dae4; border-radius:14px; display:grid; place-items:center;
  transition:border-color var(--pf-dur-1) var(--pf-ease), background var(--pf-dur-1) var(--pf-ease); }
.pf-media-drop.pf-media-round{ border-radius:999px; }
.pf-media-drop:hover{ border-color:var(--pf-primary); background:var(--pf-primary-soft); }
.pf-media-drop.is-dragover{ border-style:solid; border-color:var(--pf-primary);
  background:var(--pf-primary-soft-2); box-shadow:0 0 0 4px var(--pf-ring); }
.pf-media-hit{ position:absolute; inset:0; z-index:2; cursor:pointer; }
.pf-media-img{ width:100%; height:100%; object-fit:cover; display:block; }
.pf-media-img[data-empty]{ opacity:0; }
.pf-media-empty{ position:absolute; inset:0; display:flex; flex-direction:column; gap:.25rem;
  align-items:center; justify-content:center; color:var(--pf-muted); pointer-events:none; padding:.5rem; text-align:center; }
.pf-media-empty-txt{ font-size:.72rem; font-weight:600; color:var(--pf-muted); }
.pf-media-pdf{ display:flex; flex-direction:column; gap:.35rem; align-items:center; color:#64748b; padding:.75rem; }
.pf-media-pdfname{ font-size:.75rem; font-weight:600; max-width:100%; overflow:hidden;
  text-overflow:ellipsis; white-space:nowrap; }
/* Hover "Replace" overlay — only meaningful once a file is present. */
.pf-media-overlay{ position:absolute; inset:0; z-index:1; display:flex; align-items:center; justify-content:center;
  gap:.35rem; font-size:.8rem; font-weight:700; color:#fff; background:rgba(15,23,42,.45);
  opacity:0; transition:opacity var(--pf-dur-1) var(--pf-ease); pointer-events:none; }
.pf-media-drop.has-file:hover .pf-media-overlay{ opacity:1; }
.pf-media-round .pf-media-overlay,.pf-media-drop.pf-media-round + * .pf-media-overlay{ border-radius:999px; }
.pf-media.has-file .pf-media-empty{ display:none; }
.pf-media-pdflink{ display:inline-flex; align-items:center; gap:.3rem; margin-top:.5rem;
  font-size:.78rem; font-weight:600; color:var(--pf-primary-ink); }
.pf-media-pdflink:hover{ text-decoration:underline; }

/* Actions: mobile = equal-width row (1 action = full width); desktop = compact. */
.pf-media-actions{ display:flex; gap:.5rem; align-items:center; }
.pf-media-actions > *{ flex:1 1 0; min-width:0; }
@media (min-width:640px){ .pf-media-actions > *{ flex:0 0 auto; } }

/* Info popover — hides the technical ratio/dimension detail until asked.
   Anchored to the full-width field (not the icon) so it never overflows a
   narrow column; capped to the viewport width. */
.pf-media{ position:relative; }
.pf-media-info{ display:inline-flex; color:#94a3b8; cursor:help; }
.pf-media-info:hover,.pf-media-info:focus-visible{ color:var(--pf-primary-ink); outline:none; }
.pf-media-pop{ position:absolute; left:0; top:1.75rem; z-index:20;
  width:16rem; max-width:calc(100vw - 2rem);
  background:#0f172a; color:#e2e8f0; font-size:.72rem; font-weight:500; line-height:1.5;
  padding:.55rem .7rem; border-radius:10px; box-shadow:0 8px 24px -6px rgba(15,23,42,.4);
  opacity:0; visibility:hidden; transform:translateY(-3px); transition:var(--pf-dur-1) var(--pf-ease); pointer-events:none; }
.pf-media-info:hover .pf-media-pop,.pf-media-info:focus-visible .pf-media-pop,.pf-media-info.open .pf-media-pop{
  opacity:1; visibility:visible; transform:none; }
/* On phones AND tablets the popover becomes a bottom sheet — fixed,
   thumb-reachable, never clipped by a narrow column (tap the info icon to
   toggle). The absolute variant only survives at ≥1024px, where the dashboard
   column is wide enough that a 16rem popover anchored at left:0 still lands
   inside the viewport; below that it overhung the right edge. */
@media (max-width:1023.98px){
  .pf-media-pop{ position:fixed; left:.75rem; right:.75rem; bottom:.75rem; top:auto;
    width:auto; max-width:none; font-size:.8rem; padding:.85rem 1rem; border-radius:14px;
    box-shadow:0 -8px 32px rgba(15,23,42,.35); transform:translateY(6px); z-index:70; }
}

/* Upload state line: Preparing / Cropping / Compressing / Uploading /
   Complete / Error. */
.pf-media-status{ margin-top:.3rem; font-size:.72rem; font-weight:600; color:#64748b; }
.pf-media-status.is-busy{ color:var(--pf-primary-ink); }
/* The animated ellipsis is a FIXED, NON-WRAPPING slot.
   At .5em it was narrower than the "..." it animates to, so the content wrapped
   to one character per line inside the inline-block and baseline alignment
   pushed the first two dots ABOVE the sentence — two dots floating over
   "Uploading…" with no visible cause. It only showed while a status stayed busy
   for more than a moment, which is now the normal case for an upload in
   flight. Wide enough for three dots, and told never to wrap. */
.pf-media-status.is-busy::after{ content:""; display:inline-block; width:1.5em; text-align:left;
  white-space:pre; animation:pf-ellipsis 1.2s steps(4,end) infinite; }
@keyframes pf-ellipsis{ 0%{content:"";} 25%{content:".";} 50%{content:"..";} 75%{content:"...";} }
.pf-media-status.is-ok{ color:var(--pf-success,#059669); }
.pf-media-status.is-err{ color:var(--pf-danger,#dc2626); }

/* Moderation state of a single media slot (media_field "review" data).
   The frame above keeps showing the APPROVED image — this strip is what
   tells the owner a replacement is queued, and shows them which one. */
.pf-media-review{ display:flex; align-items:flex-start; gap:.5rem; margin-top:.6rem;
  padding:.5rem .6rem; border-radius:10px; font-size:.72rem; line-height:1.45; border:1px solid; }
.pf-media-review strong{ font-weight:700; }
.pf-media-review--pending{ background:#fffbeb; border-color:#fde68a; color:#92400e; }
.pf-media-review--rejected{ background:#fef2f2; border-color:#fecaca; color:#b91c1c; }
.pf-media-review-thumb{ width:2.25rem; height:2.25rem; object-fit:cover; border-radius:7px; flex:none;
  border:1px solid rgba(0,0,0,.08); }
.pf-media-review-thumb.pf-media-round{ border-radius:999px; }

/* ---- Signup flow (register): OTP input + username availability ------- */
.pf-otp{ font-size:1.5rem; letter-spacing:.6em; text-align:center; font-weight:700; }
.pf-un-ok, .pf-help.pf-un-ok{ color:#059669; font-weight:600; }
.pf-un-bad, .pf-help.pf-un-bad{ color:#dc2626; font-weight:600; }
.pf-input-ok{ border-color:#10b981 !important; box-shadow:0 0 0 3px rgba(16,185,129,.15); }
.pf-input-bad{ border-color:#ef4444 !important; box-shadow:0 0 0 3px rgba(239,68,68,.12); }
[data-resend-cooldown][disabled]{ color:#9ca3af; cursor:default; }
[data-username-submit][disabled]{ opacity:.55; cursor:not-allowed; transform:none; }

/* ---- Password reset: 6-box code entry (auth/reset) -------------------
   States: is-checking (dimmed), is-ok (green + check), is-bad (red + one
   shake). The password section reveals via .pf-reveal once verified. */
.pf-otp-row{ display:flex; gap:.5rem; justify-content:space-between; }
.pf-otp-cell{ width:2.85rem; height:3.3rem; min-width:0; text-align:center; font-size:1.3rem;
  font-weight:700; color:var(--pf-text); background:#fff; border:1px solid #e5e7eb;
  border-radius:10px; caret-color:var(--pf-primary);
  transition:border-color var(--pf-dur-1) var(--pf-ease), box-shadow var(--pf-dur-1) var(--pf-ease), background var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease); }
.pf-otp-cell:focus{ border-color:var(--pf-primary); box-shadow:0 0 0 4px var(--pf-ring); outline:none; }
@media (max-width:359px){ .pf-otp-row{ gap:.35rem; } .pf-otp-cell{ width:2.5rem; height:3rem; font-size:1.15rem; } }
.pf-otp-row.is-checking .pf-otp-cell{ opacity:.55; }
.pf-otp-row.is-ok .pf-otp-cell{ border-color:var(--pf-success); background:#ecfdf5; color:#047857;
  box-shadow:0 0 0 3px rgba(16,185,129,.15); }
.pf-otp-row.is-bad .pf-otp-cell{ border-color:var(--pf-danger); background:#fef2f2; color:#b91c1c;
  box-shadow:0 0 0 3px rgba(239,68,68,.12); }
@keyframes pf-shake{ 10%,90%{transform:translateX(-1px);} 20%,80%{transform:translateX(2px);}
  30%,50%,70%{transform:translateX(-4px);} 40%,60%{transform:translateX(4px);} }
.pf-otp-row.is-shake{ animation:pf-shake .5s cubic-bezier(.36,.07,.19,.97) both; }
.pf-otp-msg{ display:flex; align-items:center; gap:.35rem; min-height:1.25rem; margin-top:.6rem;
  font-size:.8rem; font-weight:600; color:#64748b; }
.pf-otp-msg.is-ok{ color:#047857; }
.pf-otp-msg.is-bad{ color:#b91c1c; }
.pf-otp-msg.is-checking::after{ content:""; display:inline-block; width:.5em; text-align:left;
  animation:pf-ellipsis 1.2s steps(4,end) infinite; }
.pf-reveal{ overflow:hidden; transition:max-height .45s cubic-bezier(.2,.7,.2,1), opacity .3s ease .12s; }
.pf-reveal.is-collapsed{ max-height:0; opacity:0; visibility:hidden; }
.pf-reveal.is-open{ max-height:26rem; opacity:1; visibility:visible; }
@media (prefers-reduced-motion:reduce){
  .pf-otp-row.is-shake{ animation:none; }
  .pf-reveal{ transition:none; }
}

/* ---- Admin Plan Controls -------------------------------------------- */
.pf-plan-table{ min-width:560px; }
.pf-num-input{ width:5.5rem; padding:.35rem .55rem; font-size:.85rem; }
.pf-num-input:disabled{ background:#f1f5f9; color:#94a3b8; }

/* Retain-and-lock: content over the plan limit stays editable but is
   visually parked (and clamped out of the public page server-side). */
/* A row the plan does not currently publish. It is NOT an error and must not
   be dressed as one: the data is saved, intact, and comes back on its own when
   the plan does. This was amber-on-amber — the same palette the product uses
   for "something needs your attention" — which read as a fault the owner had
   caused. It is now the neutral resting surface with a dashed edge, which says
   "not live" without saying "wrong". */
.pf-row-locked{ background:var(--pf-surface-strong); border-style:dashed; border-color:var(--pf-line); }
.pf-row-locked > .min-w-0{ opacity:.8; }

/* ---- Password show/hide toggle (auto-wired in app.js) ---------------- */
.pf-pw-wrap{ position:relative; }
.pf-pw-wrap .pf-input{ padding-right:2.7rem; }
.pf-pw-toggle{ position:absolute; right:.35rem; top:50%; transform:translateY(-50%);
  display:inline-flex; align-items:center; justify-content:center;
  width:2rem; height:2rem; border-radius:8px; color:#9ca3af;
  transition:color var(--pf-dur-1) var(--pf-ease), background var(--pf-dur-1) var(--pf-ease); }
.pf-pw-toggle:hover{ color:#374151; background:#f3f4f6; }
.pf-pw-toggle svg{ width:1.15rem; height:1.15rem; }

/* Phones: the avatar field centers as a unit (photo, actions, copy). */
@media (max-width:639px){
  .pf-media[data-round="1"]{ text-align:center; }
  .pf-media[data-round="1"] .pf-media-drop{ margin-inline:auto; }
  .pf-media[data-round="1"] .pf-media-head{ justify-content:center; }
  .pf-media[data-round="1"] .pf-media-actions{ justify-content:center; }
}

.pf-media.is-busy .pf-media-drop{ opacity:.6; }
.pf-media-drop.is-progress::after{ content:""; position:absolute; left:0; bottom:0; height:3px; z-index:3;
  width:var(--pf-prog,0%); background:var(--pf-primary); transition:width var(--pf-dur-2) var(--pf-ease); }

/* ---- Per-image upload progress (.pf-uprog) ----------------------------
   ONE RING PER IMAGE, ON THE IMAGE.

   The question somebody watching an upload is asking is "is MY photo in
   yet" — and a single bar under a form holding a cover and four gallery
   tiles cannot answer it. The ring sits on the preview it belongs to, over
   a scrim light enough to keep the picture readable underneath: the image
   is the label, so covering it up would leave the indicator meaningless.

   Three states and no more. Counting (0–100), processing (the server is
   re-encoding — indeterminate, because "100%" that then sits there for a
   second reads as a hang), and failed (one button: retry).

   Built by media.js, never server-rendered: it is a transient state of a
   request in flight, not a property of the page. --------------------- */
.pf-uprog{ position:absolute; inset:0; z-index:4; display:grid; place-items:center;
  background:rgba(255,255,255,.72); border-radius:inherit;
  animation:pf-fade-in var(--pf-dur-1) var(--pf-ease); }
@keyframes pf-fade-in{ from{ opacity:0; } }

/* The ring is a conic gradient with a hole punched by ::before — the same
   technique .pf-progress uses, so there is one progress-ring idea in the
   product rather than an SVG here and a gradient there. */
.pf-uprog-ring{ position:relative; width:3.5rem; height:3.5rem; border-radius:999px;
  display:grid; place-items:center;
  background:conic-gradient(var(--pf-primary) calc(var(--v,0) * 1%), rgba(17,19,24,.18) 0);
  box-shadow:0 2px 10px rgba(17,19,24,.15);
  transition:background var(--pf-dur-1) linear; }
.pf-uprog-ring::before{ content:""; position:absolute; inset:5px; border-radius:999px;
  background:var(--pf-surface); }
.pf-uprog-pct{ position:relative; font-size:.72rem; font-weight:700; color:var(--pf-text);
  font-variant-numeric:tabular-nums; }

/* Processing: a fixed quarter-arc that turns. No number, because there is
   no number — the bytes have arrived and the server is working. */
.pf-uprog.is-processing .pf-uprog-ring{
  background:conic-gradient(var(--pf-primary) 25%, rgba(17,19,24,.12) 0);
  animation:pf-uprog-spin .9s linear infinite; }
@keyframes pf-uprog-spin{ to{ transform:rotate(360deg); } }

/* Failed: the scrim turns, the preview stays. Retry is the only control
   here — Remove already exists on the field and on the tile, and offering
   it twice in two shapes is how a small overlay becomes a dialog. */
.pf-uprog.is-failed{ background:rgba(220,38,38,.14); }
.pf-uprog-retry{ display:inline-flex; align-items:center; gap:.35rem;
  padding:.4rem .7rem; border-radius:999px; border:0; cursor:pointer;
  background:var(--pf-surface); color:var(--pf-danger);
  font-size:.75rem; font-weight:700; box-shadow:0 2px 8px rgba(17,19,24,.18); }
.pf-uprog-retry:hover{ background:var(--pf-danger); color:#fff; }

/* The 72px gallery tile: the ring alone, no percentage — four characters
   at that size is a smudge, and the arc is the reading anyway. */
.pf-uprog--sm .pf-uprog-ring{ width:2.25rem; height:2.25rem; }
/* A thicker arc, proportionally: at 32px with a 3px ring the sweep was a hairline
   over a pale photo and the tile read as "doing nothing". */
.pf-uprog--sm .pf-uprog-ring::before{ inset:5px; }
.pf-uprog--sm .pf-uprog-retry{ padding:.3rem; border-radius:999px; }

@media (prefers-reduced-motion:reduce){
  .pf-uprog{ animation:none; }
  .pf-uprog.is-processing .pf-uprog-ring{ animation:none; opacity:.7; }
}

/* ---- Project gallery manager (media_gallery partial) ----------------- */
.pf-gallery-head{ display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin-bottom:.5rem; }
.pf-gallery-count{ font-size:.72rem; font-weight:700; color:var(--pf-muted); background:var(--pf-surface-strong);
  padding:.15rem .5rem; border-radius:999px; }
/* A JUSTIFIED ROW, NOT A SQUARE GRID.
   Project work is not one shape, and the editor must show the shape that is
   actually being stored — a 3:4 poster squeezed into a square tile is a second,
   invisible crop that disagrees with the file on its way to the server, so the
   editor and the public page would show two different pictures.
   Uniform ROW HEIGHT with width following the ratio (--pf-a) is what keeps a
   mixed gallery tidy while every tile stays true: one rhythm, no stretching,
   and the wrap means no horizontal overflow at any width. */
.pf-gallery-grid{ display:flex; flex-wrap:wrap; align-items:flex-start; gap:.5rem;
  --pf-grow:72px; }
@media (max-width:520px){ .pf-gallery-grid{ --pf-grow:84px; } }
/* border-box, explicitly: the tile's ratio is its whole point, and a 1px border
   added OUTSIDE the box skews a short tile more than a tall one — a 5:2 strip
   would render measurably squarer than the file it represents. */
.pf-gtile{ position:relative; box-sizing:border-box;
  height:var(--pf-grow); width:calc(var(--pf-grow) * var(--pf-a, 1));
  flex:0 0 auto; max-width:100%; border-radius:12px; overflow:hidden;
  background:#f1f5f9; border:1px solid #e5e7eb; }
/* object-fit:cover on a box that is ALREADY the image's ratio crops nothing —
   it is here only to absorb the sub-pixel rounding of the width calculation.
   `picture` is inline by default; saved tiles render through responsive_img(),
   so it has to fill the tile or the img's 100% height resolves against nothing. */
.pf-gtile picture{ display:block; width:100%; height:100%; }
.pf-gtile img{ width:100%; height:100%; object-fit:cover; display:block; }
/* Uploading: the tile that is actually in flight says so. */
.pf-gtile.is-uploading{ opacity:.65; }
.pf-gtile.is-uploading::after{ content:""; position:absolute; inset:0; z-index:3;
  background:linear-gradient(90deg,transparent,rgba(255,255,255,.45),transparent);
  animation:pf-gtile-sweep 1.1s linear infinite; }
@keyframes pf-gtile-sweep{ from{ transform:translateX(-100%); } to{ transform:translateX(100%); } }
.pf-gallery-grid.is-progress{ position:relative; }
.pf-gallery-grid.is-progress::after{ content:""; position:absolute; left:0; bottom:-.4rem; height:3px;
  width:var(--pf-prog,0%); background:var(--pf-primary); border-radius:2px;
  transition:width var(--pf-dur-2) var(--pf-ease); }
.pf-gtile.dragging{ opacity:.4; }
.pf-gtile.drop-target{ outline:2px dashed var(--pf-primary); outline-offset:-2px; }
.pf-gtile-remove{ position:absolute; top:.3rem; right:.3rem; z-index:2; width:1.5rem; height:1.5rem;
  display:grid; place-items:center; border-radius:999px; background:rgba(15,23,42,.6); color:#fff;
  transition:background var(--pf-dur-1) var(--pf-ease); }
.pf-gtile-remove:hover{ background:var(--pf-danger); }
.pf-gtile-handle{ position:absolute; bottom:.3rem; left:.3rem; z-index:2; width:1.5rem; height:1.5rem;
  display:grid; place-items:center; border-radius:7px; background:rgba(15,23,42,.55); color:#fff; cursor:grab; }
.pf-gtile-handle:active{ cursor:grabbing; }
/* A freshly picked image is SHOWN, not announced. This label used to be
   `inset:0` over an opaque lavender fill, so the moment somebody chose a photo
   the tile turned into a flat coloured square reading "New" — the preview was
   in the DOM the whole time, painted over. The tile is the preview; the marker
   is a corner badge on the same geometry as the review flag below it. */
.pf-gtile-pending{ position:absolute; left:.3rem; bottom:.3rem; z-index:2; display:inline-flex;
  align-items:center; gap:.2rem; padding:.1rem .35rem; border-radius:999px;
  background:var(--pf-primary); color:#fff; font-size:.6rem; font-weight:700; letter-spacing:.01em; }
/* Unsaved: a primary ring separates "chosen, in this form" from the saved tiles
   beside it without touching the image itself. The entrance animation ends at
   opacity 1 on its own — a preview must never depend on a JS class arriving in
   order to become visible, which is the failure this whole change is about. */
@keyframes pf-gtile-in{ from{ opacity:0; transform:scale(.94); } to{ opacity:1; transform:none; } }
.pf-gtile--new{ border-color:var(--pf-primary); box-shadow:0 0 0 2px var(--pf-primary-soft-2);
  animation:pf-gtile-in var(--pf-dur-2) var(--pf-ease) both; }
/* Awaiting moderation: desaturated + amber-flagged, so a queued image reads
   as "not live yet" at a glance and can never be confused with a saved one.
   It is deliberately NOT draggable — it has no position in the gallery. */
.pf-gtile--review{ border-color:#fcd34d; }
.pf-gtile--review img{ filter:grayscale(.55) opacity(.75); }
.pf-gtile-flag{ position:absolute; left:.3rem; bottom:.3rem; z-index:2; display:inline-flex; align-items:center;
  gap:.2rem; padding:.1rem .35rem; border-radius:999px; background:#b45309; color:#fff;
  font-size:.6rem; font-weight:700; letter-spacing:.01em; }
/* The add box keeps the row's height and stays square, so it reads as the one
   fixed element the varying tiles are laid against. */
.pf-gadd{ display:flex; flex-direction:column; gap:.3rem; align-items:center; justify-content:center;
  flex:0 0 auto; box-sizing:border-box; height:var(--pf-grow); width:var(--pf-grow);
  border-radius:12px; border:1.5px dashed #d3dae4; background:#f8fafc; color:#64748b;
  cursor:pointer; transition:border-color var(--pf-dur-1) var(--pf-ease), background var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease); }
.pf-gadd:hover{ border-color:var(--pf-primary); background:var(--pf-primary-soft); color:var(--pf-primary-ink); }
.pf-gadd.is-dragover{ border-style:solid; border-color:var(--pf-primary); background:var(--pf-primary-soft-2);
  box-shadow:0 0 0 4px var(--pf-ring); }
.pf-gadd-txt{ font-size:.68rem; font-weight:700; }

/* ---- Report-a-profile reason picker (partials/report_form) -----------
   Radios rather than a <select>: eight reasons that each need a line of
   explanation, and the whole value of the moderation queue depends on the
   reporter picking the right one. The native input stays in the DOM (it is
   the accessible control and the focus target) and the row is styled around
   it — no custom-built radio, no aria to keep in sync. */
/* No inner max-height/scroller on purpose: the modal card already scrolls
   (.pf-modal-card--lg), and a scroll area nested inside a scrolling dialog
   hides options behind a scrollbar most people never notice — on a reason
   picker that silently biases every report toward the first few categories. */
.pf-report-reasons{ display:flex; flex-direction:column; gap:.35rem;
  padding:.15rem; margin:.15rem -.15rem 0; }
.pf-report-reason{ display:flex; align-items:flex-start; gap:.6rem; padding:.55rem .7rem; cursor:pointer;
  border:1px solid var(--pf-border,#e5e7eb); border-radius:10px; background:var(--pf-surface,#fff);
  transition:border-color var(--pf-dur-1) var(--pf-ease), background var(--pf-dur-1) var(--pf-ease); }
.pf-report-reason:hover{ border-color:#cbd5e1; background:var(--pf-surface-soft,#f8fafc); }
.pf-report-reason input{ margin-top:.2rem; flex:none; accent-color:var(--pf-danger,#dc2626); }
.pf-report-reason span{ min-width:0; }
.pf-report-reason strong{ display:block; font-size:.82rem; font-weight:600; color:var(--pf-text); }
.pf-report-reason em{ display:block; font-style:normal; font-size:.72rem; line-height:1.4; color:var(--pf-muted); }
.pf-report-reason:has(input:checked){ border-color:var(--pf-danger,#dc2626); background:#fef2f2; }
.pf-report-reason:focus-within{ outline:2px solid var(--pf-primary); outline-offset:1px; }

/* Subtle, deliberately low-contrast report affordance in the public profile
   footer. It must be findable and never compete with the owner's content. */
.pf-report-link{ opacity:.75; }
.pf-report-link:hover{ opacity:1; text-decoration:underline; }

/* ---- Feedback: type picker, star rating, star readout -----------------
   The type picker REUSES the reason rows above — same anatomy, same
   accessible control — with .pf-reasons-brand swapping the checked colour
   from danger red to brand blue. Feedback is not an accusation and must
   not be dressed as one.

   The rating is a radio group drawn as stars. Two rules keep it honest:
   no star is checked by default (a pre-filled score is a made-up score),
   and "Not rated" is a real radio in the same group, so a mis-clicked star
   is undoable with no JavaScript. Filling is driven by :has() on the group
   — CSS cannot select PRECEDING siblings, so each level gets its own rule
   rather than the classic row-reverse trick, which would also drag the
   "Not rated" control to the wrong end of the row. */
.pf-reasons-brand .pf-report-reason input{ accent-color:var(--pf-primary); }
.pf-reasons-brand .pf-report-reason:has(input:checked){ border-color:var(--pf-primary); background:var(--pf-primary-soft); }

.pf-rate-row{ display:flex; align-items:center; flex-wrap:wrap; gap:.6rem; }
.pf-rate{ display:flex; align-items:center; gap:.1rem; }
.pf-rate label{ display:inline-grid; place-items:center; padding:.15rem; border-radius:8px;
  color:#d4d4d8; cursor:pointer; transition:color var(--pf-dur-1) var(--pf-ease), transform var(--pf-dur-1) var(--pf-ease); }
.pf-rate label svg{ fill:currentColor; }
.pf-rate label:hover{ transform:translateY(-1px); }
.pf-rate input:focus-visible + label{ outline:2px solid var(--pf-primary); outline-offset:1px; }
/* Checked: this star and every star before it. Suspended while the row is
   hovered so the hover preview below is what the pointer sees. */
.pf-rate:not(:hover):has(input[value="1"]:checked) label:nth-of-type(-n+1),
.pf-rate:not(:hover):has(input[value="2"]:checked) label:nth-of-type(-n+2),
.pf-rate:not(:hover):has(input[value="3"]:checked) label:nth-of-type(-n+3),
.pf-rate:not(:hover):has(input[value="4"]:checked) label:nth-of-type(-n+4),
.pf-rate:not(:hover):has(input[value="5"]:checked) label:nth-of-type(-n+5){ color:#f59e0b; }
/* Hover preview: the star under the pointer and everything before it. */
.pf-rate label:hover,
.pf-rate label:has(~ label:hover){ color:#fbbf24; }

/* The clear control only exists once there is something to clear. */
.pf-rate-clear{ display:none; font-size:.72rem; font-weight:600; color:var(--pf-muted);
  padding:.2rem .55rem; border-radius:999px; cursor:pointer; }
.pf-rate-row:has(.pf-rate input:checked) .pf-rate-clear{ display:inline-flex; align-items:center; }
.pf-rate-clear:hover{ background:var(--pf-surface-soft,#f8fafc); color:var(--pf-text); }
.pf-rate-none:focus-visible + .pf-rate-clear{ outline:2px solid var(--pf-primary); outline-offset:1px; }

/* Read-only star readout (dashboard history + admin queue). A 1–2 star
   score turns red: the whole point of showing it is to make the bad ones
   impossible to skim past. */
.pf-stars{ display:inline-flex; align-items:center; gap:.05rem; color:#d4d4d8; }
.pf-stars span{ display:inline-grid; place-items:center; }
.pf-stars .is-on{ color:#f59e0b; }
.pf-stars .is-on svg{ fill:currentColor; }
.pf-stars.is-low .is-on{ color:#dc2626; }

/* ---- Moderation review surfaces (admin: approvals + reports) ---------
   Two components, both about looking at images fast:
   .pf-review-strip   a horizontal contact sheet of everything a profile
                      would publish (scrolls inside the card, never the page)
   .pf-review-compare current image → proposed replacement, side by side.
   The compare row is the load-bearing one: a moderator decides whether a
   REPLACEMENT is acceptable, which is a different question from whether an
   image is acceptable on its own. It stacks below 640px, where two images
   side by side are too small to judge. */
.pf-review-strip{ display:flex; gap:.5rem; overflow-x:auto; padding-bottom:.25rem; }
.pf-review-shot{ flex:none; width:5rem; aspect-ratio:1; border-radius:9px; overflow:hidden;
  border:1px solid #e5e7eb; background:#f1f5f9; }
.pf-review-shot--wide{ width:10rem; aspect-ratio:16/5; }
.pf-review-shot img{ width:100%; height:100%; object-fit:cover; display:block; }

.pf-review-compare{ display:flex; align-items:center; gap:.75rem; }
.pf-review-cell{ flex:1 1 0; min-width:0; margin:0; }
.pf-review-cell figcaption{ font-size:.66rem; font-weight:700; text-transform:uppercase; letter-spacing:.03em;
  color:var(--pf-muted); margin-bottom:.3rem; }
.pf-review-cell img{ width:100%; max-height:14rem; object-fit:contain; display:block; border-radius:10px;
  border:1px solid #e5e7eb; background:#f8fafc; }
.pf-review-cell--new figcaption{ color:#b45309; }
.pf-review-cell--new img{ border-color:#fcd34d; }
.pf-review-arrow{ flex:none; color:var(--pf-muted); }
@media (max-width:639px){
  .pf-review-compare{ flex-direction:column; align-items:stretch; gap:.5rem; }
  .pf-review-arrow{ align-self:center; transform:rotate(90deg); }
}

/* A decision in flight. Deliberately quiet — a review queue is worked in
   runs, and a card that flashes, collapses or slides on every click makes a
   fast operator slower. The card dims, stops taking pointer events (the
   buttons are also `disabled`, this is the belt to that pair of braces) and
   otherwise holds its exact size, so the cards below it do not move under the
   cursor while the server answers. It is removed only once the server has
   confirmed the decision, never on the click. */
[data-approve-item].is-busy{ opacity:.55; pointer-events:none; transition:opacity var(--pf-dur-2) var(--pf-ease); }

/* ---- Crop modal (any ratio: avatar 1:1 round, cover 16:3, project 16:9).
   Reuses .pf-modal shell tokens; the stage's aspect-ratio is set inline. */
.pf-crop-stage{ position:relative; width:100%; margin:0 auto;
  background:#0f172a; border-radius:12px; overflow:hidden; touch-action:none; cursor:grab; }
.pf-crop-stage.dragging{ cursor:grabbing; }
.pf-crop-stage canvas{ position:absolute; top:0; left:0; width:100%; height:100%; }
.pf-crop-ring{ position:absolute; inset:0; box-shadow:0 0 0 9999px rgba(15,23,42,.45);
  border-radius:50%; pointer-events:none; }
.pf-crop-ring.pf-crop-rect{ border-radius:0; box-shadow:none;
  outline:1.5px solid rgba(255,255,255,.55); outline-offset:-1.5px; }
.pf-crop-tools{ display:flex; align-items:center; gap:.75rem; margin-top:1rem; }
.pf-crop-zoom{ flex:1 1 auto; min-width:0; accent-color:var(--pf-primary); }
.pf-crop-tools .pf-btn{ flex:0 0 auto; }

/* Cards — border-first, shadow almost never. Contrast does the separating. */
.pf-card{ background:var(--pf-surface); border:1px solid var(--pf-border);
  border-radius:var(--pf-radius); }
.pf-card-soft{ background:var(--pf-surface-soft); border:1px solid var(--pf-border);
  border-radius:var(--pf-radius); }
.pf-card-raised{ box-shadow:var(--pf-shadow-md); }

/* Interactive card — ONE hover/focus behavior for every clickable card
   (dashboard KPIs, quick actions, admin stat cards, theme pickers).
   Fast Linear-style lift; focus ring matches the global focus style. */
.pf-card-link{ transition:transform var(--pf-dur-1) var(--pf-ease), box-shadow var(--pf-dur-1) var(--pf-ease), border-color var(--pf-dur-1) var(--pf-ease); }
.pf-card-link:hover{ transform:translateY(-2px); border-color:#e2e8f0;
  box-shadow:0 10px 28px -10px rgba(15,23,42,.16); }
.pf-card-link:active{ transform:translateY(0); transition-duration:.06s; }
.pf-row-link{ transition:background-color var(--pf-dur-1) var(--pf-ease); }
.pf-row-link:hover{ background:#f8fafc; }
.pf-card-link .pf-card-go, .pf-row-link .pf-card-go{ opacity:0; transform:translateX(-3px);
  transition:opacity var(--pf-dur-1) var(--pf-ease), transform var(--pf-dur-1) var(--pf-ease); color:#94a3b8; }
.pf-card-link:hover .pf-card-go, .pf-row-link:hover .pf-card-go{ opacity:1; transform:none; }
@media (prefers-reduced-motion:reduce){
  .pf-card-link, .pf-row-link, .pf-card-link .pf-card-go, .pf-row-link .pf-card-go{ transition:none; }
  .pf-card-link:hover{ transform:none; }
}

/* Native file inputs — match .pf-btn-ghost so upload fields belong to the
   same system as every other control (profile photos, CV, project images). */
input[type="file"]{ font-size:.85rem; color:#6b7280; max-width:100%; }
input[type="file"]::file-selector-button{ margin-right:.75rem; padding:.5rem 1rem;
  border-radius:10px; border:1px solid #e5e7eb; background:#fff; color:#111827;
  font-weight:600; font-size:.85rem; cursor:pointer;
  transition:background var(--pf-dur-1) var(--pf-ease), border-color var(--pf-dur-1) var(--pf-ease); }
input[type="file"]::file-selector-button:hover{ background:#f9fafb; border-color:#d1d5db; }

/* ---- Form controls --------------------------------------------------- */
.pf-input{ width:100%; border:1px solid var(--pf-border); border-radius:var(--pf-radius-ctl);
  padding:.55rem .75rem; min-height:2.5rem; font-size:.9rem; color:var(--pf-text);
  background:var(--pf-surface);
  transition:border-color var(--pf-dur-1) var(--pf-ease), box-shadow var(--pf-dur-1) var(--pf-ease), background-color var(--pf-dur-1) var(--pf-ease); }
.pf-input::placeholder{ color:var(--pf-muted); }
.pf-input:hover:not(:disabled):not(:focus){ border-color:var(--pf-border-strong); }
.pf-input:focus{ border-color:var(--pf-primary); box-shadow:0 0 0 3px var(--pf-ring); outline:none; }
.pf-input:disabled,.pf-input[readonly]{ background:var(--pf-surface-strong); color:var(--pf-muted);
  cursor:not-allowed; }
.pf-input[aria-invalid="true"],.pf-input.is-error{ border-color:var(--pf-danger); }
.pf-input[aria-invalid="true"]:focus,.pf-input.is-error:focus{ box-shadow:0 0 0 3px rgba(220,38,38,.14); }
@media (pointer:coarse){ .pf-input{ min-height:2.75rem; } }

/* ---- Native form controls ------------------------------------------------
   @tailwindcss/forms hard-codes #2563eb — Tailwind's blue, not ours — as the
   fill of every checked checkbox and radio and as their focus ring. That was
   the last place the old blue survived, and it survived on literally every
   settings page. Rebound here rather than in the Tailwind config because the
   plugin emits it as a base style, which a theme extension cannot reach.

   :checked paints with currentColor, so `color` IS the tick fill. The range
   thumb/track follow accent-color, which takes the exact brand violet: it is
   a control surface, not type, so it does not need the darkened rendering. */
[type='checkbox'], [type='radio']{ color:var(--pf-primary-ink); }
[type='checkbox']:focus, [type='radio']:focus{ --tw-ring-color:var(--pf-primary-ink); }
[type='range']{ accent-color:var(--pf-brand-violet); }
/* Switches are built from a peer checkbox + a styled track (see the
   peer-checked utilities in the views); the track colour is the brand. */
.pf-switch-track{ background:var(--pf-border-strong); transition:background-color var(--pf-dur-1) var(--pf-ease); }
.peer:checked ~ .pf-switch-track{ background:var(--pf-primary-ink); }

/* Native selects: the forms plugin strips the arrow — put ours back so a
   select never reads as a text input. */
select.pf-input{ appearance:none; -webkit-appearance:none; padding-right:2.1rem;
  background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%235F6673' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat:no-repeat; background-position:right .6rem center; background-size:1rem; }
textarea.pf-input{ min-height:6rem; line-height:1.6; }
/* Compact inline textarea (e.g. admin approval "reason") — a real multiline
   field that stays short: full-width on mobile, flexes beside the action
   buttons on desktop. Double-class beats the element+class 6rem rule above. */
.pf-input.pf-reject-reason{ min-height:3rem; line-height:1.5; resize:vertical; }

.pf-label{ display:block; font-size:.8rem; font-weight:600; color:var(--pf-text); margin-bottom:.35rem; }
.pf-help{ font-size:.75rem; color:var(--pf-muted); margin-top:.3rem; line-height:1.5; }
.pf-error{ font-size:.78rem; color:var(--pf-danger); margin-top:.3rem; font-weight:500; }

/* ---- SaveStatus  (save_status() in helpers.php, [data-save-status]) -------
   One quiet line. It carries an icon-free dot rather than a spinner: a
   spinner on a form that saves eleven times in a sitting is eleven pieces of
   motion in the corner of the eye, and the thing being reported is usually
   over in 80 ms. Colour is never the only signal — the wording differs in
   every state. */
.pf-save-status{ display:inline-flex; align-items:center; gap:.4rem;
  min-height:1.5rem; font-size:.78rem; font-weight:500; color:var(--pf-muted);
  line-height:1.4; }
.pf-save-status[hidden]{ display:none; }
.pf-save-status::before{ content:""; width:.4rem; height:.4rem; border-radius:999px;
  flex:0 0 auto; background:var(--pf-border-strong); }
.pf-save-status[data-state="saving"]::before{ background:var(--pf-primary); }
.pf-save-status[data-state="saved"]{ color:var(--pf-success); }
.pf-save-status[data-state="saved"]::before{ background:var(--pf-success); }
.pf-save-status[data-state="error"]{ color:var(--pf-danger); }
.pf-save-status[data-state="error"]::before{ background:var(--pf-danger); }
.pf-save-retry{ font:inherit; font-weight:600; color:var(--pf-danger);
  text-decoration:underline; text-underline-offset:2px; cursor:pointer;
  background:none; border:0; padding:.25rem; margin:-.25rem; }
.pf-save-retry:focus-visible{ outline:2px solid var(--pf-danger); outline-offset:1px; border-radius:6px; }
@media (max-width:639.98px){
  /* A 44px target for Retry without making the line itself 44px tall. */
  .pf-save-retry{ padding:.6rem .5rem; margin:-.6rem -.2rem; }
}

/* ---- FieldHelp  (field_label()/field_help() in helpers.php, [data-fhelp]) ----
   The label row keeps the label's own bottom margin so a field WITH help and a
   field WITHOUT sit on the same baseline down a column. The margin moves from
   the label onto the row; nothing else changes. */
.pf-label-row{ display:flex; align-items:center; gap:.3rem; margin-bottom:.35rem; }
.pf-label-row .pf-label.pf-label-inline{ margin-bottom:0; }

.pf-fhelp{ position:relative; display:inline-flex; flex:0 0 auto; }

/* A checkbox and its help control on one row. The help button is OUTSIDE the
   <label> on purpose: inside it, every tap aimed at the popover would also
   toggle the box. */
.pf-check-row{ display:flex; align-items:center; gap:.3rem; }
.pf-check{ display:inline-flex; align-items:center; gap:.5rem; min-height:2.75rem;
  font-size:.875rem; color:var(--pf-text); cursor:pointer; }
.pf-check input{ flex:0 0 auto; }

/* 28px on a pointer, 44px under a finger (below). The icon is 16px; the button
   is not — a help control small enough to miss is a help control nobody uses. */
.pf-fhelp-btn{ display:inline-grid; place-items:center; width:1.75rem; height:1.75rem;
  margin:-.25rem; border-radius:999px; color:var(--pf-muted-2); cursor:help;
  background:transparent; border:0; -webkit-tap-highlight-color:transparent;
  transition:color var(--pf-dur-1) var(--pf-ease), background-color var(--pf-dur-1) var(--pf-ease); }
.pf-fhelp-btn:hover{ color:var(--pf-primary-ink); background:var(--pf-primary-soft); }
.pf-fhelp-btn:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:1px; }

/* Not [hidden]: the popover has to stay in the DOM and in the accessibility
   tree, because the INPUT points at it with aria-describedby. Reveal is
   opacity + visibility, which is the same contract .pf-media-pop uses. */
.pf-fhelp-pop{ position:absolute; left:-.5rem; top:calc(100% + .3rem); z-index:60;
  width:max-content; max-width:min(19rem, calc(100vw - 2rem));
  padding:.5rem .65rem; border-radius:var(--pf-radius-ctl);
  background:var(--pf-text); color:#fff; font-size:.75rem; font-weight:400;
  line-height:1.5; text-align:left; white-space:normal;
  box-shadow:var(--pf-shadow-md);
  opacity:0; visibility:hidden; transform:translateY(-2px); pointer-events:none;
  transition:opacity var(--pf-dur-2) var(--pf-ease), transform var(--pf-dur-2) var(--pf-ease),
             visibility var(--pf-dur-2); }
.pf-fhelp:hover .pf-fhelp-pop,
.pf-fhelp-btn:focus-visible + .pf-fhelp-pop,
.pf-fhelp.is-open .pf-fhelp-pop{
  opacity:1; visibility:visible; transform:none; pointer-events:auto; }

/* A pinned popover (clicked, not hovered) keeps the trigger lit so the state
   is visible without colour being the only signal — the icon fills in. */
.pf-fhelp.is-open .pf-fhelp-btn{ color:var(--pf-primary-ink); background:var(--pf-primary-soft); }

/* Near the right edge of a two-column grid the popover would overflow; the
   controller flips this class on and it anchors from the right instead. */
.pf-fhelp.is-flipped .pf-fhelp-pop{ left:auto; right:-.5rem; }

@media (max-width:639.98px){
  .pf-fhelp-btn{ width:2.75rem; height:2.75rem; margin:-.5rem; }
  /* The 44px button already supplies its own optical padding; the row's gap on
     top of it reads as a stray icon rather than one attached to the label. */
  .pf-label-row, .pf-check-row{ gap:0; }
  /* Docked, not anchored: a 19rem bubble hanging off a control at 320px is a
     bubble half off-screen. Same treatment .pf-media-pop already uses. */
  .pf-fhelp-pop{ position:fixed; left:.75rem; right:.75rem; top:auto;
    bottom:calc(.75rem + env(safe-area-inset-bottom)); width:auto; max-width:none;
    padding:.85rem 1rem; font-size:.8125rem; transform:translateY(4px); }
  .pf-fhelp:hover .pf-fhelp-pop{ opacity:0; visibility:hidden; pointer-events:none; }
  .pf-fhelp.is-open .pf-fhelp-pop{ opacity:1; visibility:visible; transform:none; pointer-events:auto; }
}
@media (prefers-reduced-motion:reduce){
  .pf-fhelp-pop{ transition:none; }
}
/* Form-level message (a rule about the whole row, not one box). */
.pf-form-error{ display:flex; align-items:flex-start; gap:.5rem; font-size:.8rem; font-weight:500;
  color:var(--pf-danger); background:rgba(220,38,38,.06); border:1px solid rgba(220,38,38,.22);
  border-radius:var(--pf-radius-ctl); padding:.55rem .7rem; line-height:1.5; }

/* ---- Prefixed URL / username field ----------------------------------
   The protocol (or a platform's base address) is chrome, not something to
   type: it sits in a non-editable chip welded to the input, so the box only
   ever holds what the user actually owns — a domain or a handle. The chip
   hides itself when the value already carries a scheme (a legacy http://
   link), so it can never read as "https://http://…". */
.pf-affix{ display:flex; align-items:stretch; width:100%; min-width:0;
  border:1px solid var(--pf-border); border-radius:var(--pf-radius-ctl);
  background:var(--pf-surface); overflow:hidden;
  transition:border-color var(--pf-dur-1) var(--pf-ease), box-shadow var(--pf-dur-1) var(--pf-ease); }
.pf-affix:hover:not(:focus-within){ border-color:var(--pf-border-strong); }
.pf-affix:focus-within{ border-color:var(--pf-primary); box-shadow:0 0 0 3px var(--pf-ring); }
.pf-affix.is-error{ border-color:var(--pf-danger); }
.pf-affix.is-error:focus-within{ box-shadow:0 0 0 3px rgba(220,38,38,.14); }

.pf-affix-prefix{ display:inline-flex; align-items:center; flex:0 0 auto; max-width:55%;
  padding:0 .1rem 0 .75rem; font-size:.85rem; color:var(--pf-muted);
  background:var(--pf-surface-strong); border-right:1px solid var(--pf-border);
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; user-select:none; }
.pf-affix-prefix.is-hidden{ display:none; }
/* The input inside the group owns no border of its own — the group draws it. */
.pf-affix .pf-affix-input{ flex:1 1 auto; min-width:0; border:0; border-radius:0; background:transparent; }
.pf-affix .pf-affix-input:hover,
.pf-affix .pf-affix-input:focus{ border:0; box-shadow:none; outline:none; }
/* ---- Social username field -------------------------------------------
   The base address is NOT in the box. `https://linkedin.com/in/` would eat
   most of a 330px form column and none of it is editable, so the input keeps
   its full width for the username and the base moves to the preview line
   below — which also shows the exact link being generated, as it is typed.
   Identical structure at every width: nothing to reflow, nothing to stack. */
.pf-handle{ display:block; }
.pf-handle-input{ width:100%; }

.pf-handle-preview{ display:flex; align-items:center; gap:.35rem; margin-top:.4rem;
  font-size:.75rem; line-height:1.4; color:var(--pf-muted); min-width:0; }
.pf-handle-preview svg{ opacity:.7; }
/* One line, clipped from the LEFT edge of the base if it must be — the
   username is the part that has to stay readable. */
.pf-handle-url{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  direction:ltr; }
.pf-handle-base{ color:var(--pf-muted); }
.pf-handle-base.is-hidden{ display:none; }
.pf-handle-name{ color:var(--pf-text); font-weight:600; }
.pf-handle-name.is-placeholder{ color:var(--pf-muted); font-weight:400; opacity:.7; }
.pf-handle .pf-input[aria-invalid="true"] ~ .pf-handle-preview .pf-handle-name{ color:var(--pf-danger); }

/* ---- Badges — semantic, never decorative -----------------------------
   Default is neutral. Colour is opt-in and always means something. */
.pf-badge{ display:inline-flex; align-items:center; gap:.3rem; font-size:.72rem; font-weight:600;
  padding:.15rem .55rem; border-radius:999px; border:1px solid transparent;
  background:var(--pf-surface-strong); color:var(--pf-muted); white-space:nowrap; }
.pf-badge-success{ background:#F0FDF4; color:var(--pf-success); border-color:#BBF7D0; }
.pf-badge-warning{ background:#FFFBEB; color:var(--pf-warning); border-color:#FDE68A; }
.pf-badge-danger { background:#FEF2F2; color:var(--pf-danger);  border-color:#FECACA; }
.pf-badge-info   { background:var(--pf-primary-soft-2); color:var(--pf-primary-ink);
                   border-color:var(--pf-primary-border); }
/* Solid variant, for the one badge per row that states WHO acted rather than
   what happened — an audit-log actor role, a plan tier. Kept in the component
   because the hand-rolled version it replaced had no `white-space:nowrap` and
   wrapped "Super Admin" onto two lines, turning a pill into a solid block that
   dominated the row it was supposed to annotate. */
.pf-badge-strong { background:var(--pf-ink); color:#fff; border-color:transparent; }
.pf-badge-xs     { font-size:.65rem; padding:.1rem .45rem; letter-spacing:.04em;
                   text-transform:uppercase; font-weight:700; }
/* Retained-but-not-public. Deliberately the quietest badge in the set — it
   annotates data that is perfectly safe, so it borrows the neutral surface
   rather than the warning one. See PlanPolicy::effective(). */
.pf-badge-retained{ display:inline-flex; align-items:center; gap:.3rem; margin-bottom:.375rem;
  font-size:.68rem; font-weight:700; letter-spacing:.02em; white-space:nowrap;
  padding:.12rem .5rem; border-radius:999px;
  background:var(--pf-surface-strong); color:var(--pf-muted); border:1px solid var(--pf-line); }

/* One-line plan state above a section list: what is saved, what is shown.
   Reads as information, never as an alert. */
.pf-limit-note{ display:flex; flex-wrap:wrap; align-items:baseline; gap:.15rem .5rem;
  padding:.6rem .85rem; border-radius:10px; border:1px solid var(--pf-line);
  background:var(--pf-surface-strong); }
.pf-limit-note-head{ font-size:.8rem; font-weight:700; color:var(--pf-text); }
.pf-limit-note-detail{ font-size:.78rem; color:var(--pf-muted); line-height:1.45; }
@media (max-width:420px){
  .pf-limit-note{ padding:.55rem .7rem; }
  .pf-limit-note-detail{ font-size:.74rem; }
}

/* ---- Filter bar ----
   The admin list pages share one filter row. Fields FLEX rather than sit at
   their content width: with `min-width` alone a 3-option select and a
   12-option select ended up visibly different widths on the same line, and on
   a phone the row wrapped into a ragged staircase. `flex:1` over a common
   basis makes every field on a line equal and lets a lone field fill the row —
   the same rule .pf-bgrid applies to collections, expressed for a form where
   pairing must survive. */
.pf-filters{ display:flex; flex-wrap:wrap; gap:.75rem; align-items:flex-end; }
.pf-filters > *{ flex:1 1 9rem; min-width:0; }
.pf-filters > .pf-filters-wide{ flex-basis:14rem; }
.pf-filters > .pf-filters-act{ flex:0 0 auto; }
.pf-filters select, .pf-filters input{ width:100%; }
.pf-badge-brand  { background:var(--pf-grad-ink); color:#fff; }
.pf-badge-grad   { background:var(--pf-grad-ink); color:#fff; }

/* ---- Tables — one table everywhere (dashboard + admin) ---------------
   The wrapper owns the horizontal scroll so a wide table never widens the
   page (see the overflow-x:clip guard at the top of this file). */
.pf-table-wrap{ overflow-x:auto; border:1px solid var(--pf-border); border-radius:var(--pf-radius);
  background:var(--pf-surface); -webkit-overflow-scrolling:touch; }
.pf-table-wrap > .pf-table{ border:0; border-radius:0; }
.pf-table{ width:100%; border-collapse:separate; border-spacing:0; font-size:.875rem; }
.pf-table thead th{ position:sticky; top:0; z-index:1; text-align:left; font-size:.72rem;
  font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--pf-muted);
  background:var(--pf-surface-soft); padding:.7rem .9rem; white-space:nowrap;
  border-bottom:1px solid var(--pf-border); }
.pf-table tbody td{ padding:.8rem .9rem; color:var(--pf-text); vertical-align:middle;
  border-bottom:1px solid var(--pf-border); }
.pf-table tbody tr:last-child td{ border-bottom:0; }
.pf-table tbody tr{ transition:background-color var(--pf-dur-1) var(--pf-ease); }
.pf-table tbody tr:hover{ background:var(--pf-surface-soft); }
.pf-table .pf-td-muted{ color:var(--pf-muted); }
.pf-table .pf-td-num{ font-variant-numeric:tabular-nums; text-align:right; }

/* ---- Pagination ------------------------------------------------------ */
.pf-page-nav{ display:flex; flex-wrap:wrap; align-items:center; gap:.3rem; }
.pf-page-link{ display:inline-flex; align-items:center; justify-content:center; min-width:2.25rem;
  height:2.25rem; padding:0 .6rem; border-radius:8px; border:1px solid var(--pf-border);
  background:var(--pf-surface); color:var(--pf-muted); font-size:.82rem; font-weight:600;
  font-variant-numeric:tabular-nums; transition:var(--pf-dur-1); }
.pf-page-link:hover{ background:var(--pf-surface-soft); color:var(--pf-text);
  border-color:var(--pf-border-strong); }
.pf-page-link.active{ background:var(--pf-ink); border-color:var(--pf-ink); color:#fff; }
.pf-page-link.disabled{ opacity:.45; pointer-events:none; }
@media (pointer:coarse){ .pf-page-link{ min-width:2.75rem; height:2.75rem; } }

/* ---- Page header — the same title block on every app page ------------ */
.pf-page-head{ display:flex; flex-wrap:wrap; align-items:flex-start; justify-content:space-between;
  gap:1rem; margin-bottom:1.5rem; }
.pf-page-title{ font-size:1.5rem; font-weight:700; letter-spacing:-.02em; color:var(--pf-text);
  line-height:1.2; }
.pf-page-sub{ font-size:.875rem; color:var(--pf-muted); margin-top:.35rem; max-width:60ch; }

/* ---- Section rhythm (marketing) -------------------------------------- */
.pf-section{ padding-block:4rem; }
@media (min-width:768px){ .pf-section{ padding-block:6rem; } }
@media (min-width:1280px){ .pf-section{ padding-block:7.5rem; } }
.pf-shell{ width:100%; max-width:1280px; margin-inline:auto; padding-inline:1.25rem; }
@media (min-width:768px){ .pf-shell{ padding-inline:2rem; } }
/* Body copy never exceeds a comfortable measure. */
.pf-lede{ font-size:1.0625rem; line-height:1.65; color:var(--pf-muted); max-width:62ch; }
@media (min-width:768px){ .pf-lede{ font-size:1.125rem; } }

/* ======================================================================
   THE LOADING SYSTEM  (.pf-pageload / .pf-navprog / .pf-skel* / .pf-op)
   ----------------------------------------------------------------------
   ONE set of loading affordances for the whole product, in four sizes,
   picked by HOW MUCH of the screen is actually unknown:

     document   the whole page is not there yet   -> .pf-pageload (the mark)
     navigation the page is being replaced        -> .pf-navprog  (2px bar)
     region     one panel is being fetched        -> .pf-skel*    (its shape)
     action     one control is working            -> button busy state
     operation  something genuinely slow          -> .pf-op       (a sentence)

   THE RULE THAT KEEPS IT HONEST: a loader is a confession that the user is
   waiting. So none of these is allowed to appear for work that is already
   done — the document loader waits 225ms before it will show itself and
   removes itself untouched if the page beat it, the progress bar completes
   with the navigation rather than on a timer, and nothing here ever delays
   content that is ready in order to finish its own animation.

   Every one of them is CSS on markup the page already shipped: no library,
   no image, no font, no request. See window.pfLoad in app.js.
   ====================================================================== */

/* ---- A · Initial document load -------------------------------------- */
/* DELIBERATELY NOT HERE. The document loader's rules are inlined by
   loading_shell() (app/lib/helpers.php) beside the script that drives it, so
   the overlay is correctly styled from the instant it is parsed and cannot be
   left as an unstyled 512px logo by a stylesheet that is slow or missing —
   which is precisely the condition it exists to cover. It is about ten lines;
   duplicating them here would be the second source of truth that eventually
   disagrees. Everything below is needed only once the page is interactive, so
   it belongs in the stylesheet. */

/* ---- B · Navigation progress ---------------------------------------- */
/* Two pixels at the very top of the viewport, in the brand gradient. It is
   never a modal and never blocks a click: a navigation the user wants to
   abandon must stay abandonable. Width is driven from script; everything else
   is here. */
.pf-navprog{ position:fixed; top:0; left:0; height:2px; width:0; z-index:9999;
  background:linear-gradient(90deg, var(--pf-primary), var(--pf-accent));
  border-end-end-radius:2px; opacity:0; pointer-events:none;
  transition:width .2s ease, opacity .22s ease .06s; }
.pf-navprog.is-on{ opacity:1; transition:width .2s ease, opacity .12s ease; }

/* ---- C · Skeletons (region loading) --------------------------------- */
/* The base plate. `.pf-skel` alone is a grey box; the shapes below give it the
   proportions of the thing that is coming, which is the whole difference
   between "something is loading" and "a list of rows is loading". */
.pf-skel{ position:relative; overflow:hidden; background:var(--pf-surface-strong);
  border-radius:8px; }
.pf-skel::after{ content:""; position:absolute; inset:0; transform:translateX(-100%);
  background:linear-gradient(90deg,transparent,rgba(255,255,255,.65),transparent);
  animation:pf-shimmer 1.4s infinite; }
@keyframes pf-shimmer{ to{ transform:translateX(100%); } }

/* ONE sweep for the whole group, not one per box. Ten rows each running their
   own 1.4s shimmer is ten offset highlights travelling in different places,
   which reads as noise; the group animation is what makes it read as one
   surface being filled. */
.pf-skels{ display:flex; flex-direction:column; gap:.55rem; }
.pf-skels > *{ animation-delay:inherit; }

/* Text lines. The last line of a paragraph is short, because a rectangle of
   identical bars does not look like text and a ragged one does. */
.pf-skel-line{ height:.7rem; border-radius:.35rem; }
.pf-skel-line.is-sm{ height:.6rem; }
.pf-skel-line.w-40{ width:40%; }
.pf-skel-line.w-60{ width:60%; }
.pf-skel-line.w-75{ width:75%; }
.pf-skel-line.w-90{ width:90%; }

/* A list row: avatar/icon square, two lines of text, a trailing meta chip —
   the anatomy every list in this product shares (messages, notifications,
   tickets, audit rows). */
.pf-skel-row{ display:flex; align-items:center; gap:.65rem; padding:.55rem .1rem; }
.pf-skel-row > .pf-skel-avatar{ flex:0 0 auto; width:2.25rem; height:2.25rem; border-radius:50%; }
.pf-skel-row > .pf-skel-rowbody{ flex:1 1 auto; min-width:0;
  display:flex; flex-direction:column; gap:.4rem; }
.pf-skel-row > .pf-skel-meta{ flex:0 0 auto; width:3.25rem; height:.6rem; border-radius:.35rem; }

/* A card: a media band over a title and two lines. */
.pf-skel-card{ border:1px solid var(--pf-border); border-radius:.6rem; padding:.75rem;
  display:flex; flex-direction:column; gap:.5rem; }
.pf-skel-card > .pf-skel-media{ height:6.5rem; border-radius:.4rem; }

/* A compact metric tile — the dashboard/analytics KPI shape. */
.pf-skel-kpi{ border:1px solid var(--pf-border); border-radius:.6rem; padding:.75rem;
  display:flex; flex-direction:column; gap:.5rem; }
.pf-skel-kpi > .pf-skel-num{ height:1.35rem; width:45%; border-radius:.35rem; }

/* A document: A4 proportions, so a preview pane does not collapse and then
   jump back to full height when the real sheet arrives. */
.pf-skel-doc{ width:100%; aspect-ratio:1 / 1.414; border-radius:.4rem; }

/* ---- D · Action (button) busy state --------------------------------- */
/* The spinner that replaces a button's label while its action is in flight.
   The button keeps the width it had — pfLoad pins it before swapping the
   content — so a row of buttons cannot reflow around the one that was
   pressed. */
.pf-btn-spin{ display:inline-block; width:.9rem; height:.9rem; flex:0 0 auto;
  border:2px solid currentColor; border-top-color:transparent; border-radius:50%;
  opacity:.85; animation:pf-spin .65s linear infinite; }

/* ---- E · Long operation --------------------------------------------- */
/* One sentence and a dot, for work that genuinely takes a second or more:
   "Preparing your CV…". It says WHAT is happening and never invents a
   percentage — a number nobody measured is a lie that erodes every honest
   number beside it. It lives in an aria-live region, so this is also what a
   screen reader is told. */
.pf-op{ display:inline-flex; align-items:center; gap:.4rem; min-height:1.1rem;
  font-size:.72rem; line-height:1.4; color:var(--pf-muted); }
.pf-op-dot{ width:.5rem; height:.5rem; flex:0 0 auto; border-radius:50%;
  border:2px solid currentColor; border-top-color:transparent;
  animation:pf-spin .7s linear infinite; }
.pf-op.is-done{ color:var(--pf-success); }
.pf-op.is-done .pf-op-dot{ animation:none; border-color:currentColor; background:currentColor; }
.pf-op.is-error{ color:var(--pf-danger); }
.pf-op.is-error .pf-op-dot{ animation:none; border-color:currentColor; background:currentColor; }

/* ---- F · Reduced motion --------------------------------------------- */
/* Nothing moves, nothing scales, nothing sweeps. What survives is the STATE:
   a mark that is present, a bar that has a width, a plate that is grey. The
   information is in the presence, not in the animation, which is what makes
   removing the animation lossless rather than a downgrade. */
@media (prefers-reduced-motion:reduce){
  /* The document loader carries its own reduced-motion rule inline — see
     loading_shell(). */
  .pf-navprog{ transition:opacity .01ms; }
  .pf-skel::after{ animation:none; display:none; }
  .pf-btn-spin, .pf-op-dot{ animation:none; border-top-color:currentColor; opacity:.6; }
}

/* ======================================================================
   BALANCED GRID (.pf-bgrid) — the global grid rule
   ======================================================================
   ONE component for every repeating equal-item collection in the product:
   action rows, social links, dashboard buttons, modal actions, card and
   tile grids, option pickers, toolbars. The rule it enforces:

     1. The column count is responsive (mobile / tablet / desktop tiers).
     2. Every item in a COMPLETE row has exactly the same width.
     3. An INCOMPLETE final row still fills the full grid width — one
        leftover item spans the whole row, two or more split it equally.
     4. Gaps, heights, alignment and padding are unchanged either way.

   Why flexbox and not `display:grid`
   ----------------------------------
   CSS Grid cannot do rule 3: `repeat(N, 1fr)` leaves the short last row
   ragged with dead space beside it, and there is no selector that knows
   `count % columns` — the remainder changes with the breakpoint, so it
   cannot be baked into the markup either. Spanning tracks needs an
   integer c/r, which does not exist for e.g. 2 items in a 3-column grid.

   Flexbox solves it exactly, with no JS and no item count anywhere:

     basis = (100% - (cols - 1) * gap) / cols     ← precisely one column
     flex-grow: 1                                 ← absorbs the free space

   A full row's bases plus its gaps sum to exactly 100%, so there is no
   free space and every item stays at one column width (rule 2). A short
   row of r items leaves free space, and equal grow factors over equal
   bases divide it equally — so all r items stay equal to each other AND
   together fill the row edge to edge (rule 3). r = 1 gets all of it, i.e.
   full width. The remainder is handled by layout, never by a count.

   The `- .05px` hair is a wrap guard: browsers lay out in 1/64px units,
   so a basis that is mathematically exactly 1/cols can round up and wrap
   one item early. Shaving a sub-pixel makes that impossible — and
   flex-grow hands the shaved sub-pixel straight back, so complete rows
   are still pixel-exact.

   Usage: `.pf-bgrid` + a column modifier per tier + a gap modifier, e.g.
   `class="pf-bgrid pf-bgrid-2 pf-bgrid-lg-4 pf-bgrid-g4"`. Tiers are
   mobile-first and match Tailwind's (sm 640 / md 768 / lg 1024 / xl 1280),
   so converting `grid grid-cols-2 lg:grid-cols-4 gap-4` is mechanical.
   ---------------------------------------------------------------------- */
/* The formula lives here ONCE, for every balanced container in the product.
   It has to be declared on the same element that resolves --pf-bgrid-cols:
   var() inside a custom property is substituted where that property is
   computed, so hoisting this to :root would freeze the column count at the
   root's value and no breakpoint would ever change it. */
.pf-bgrid,
.pf-actions,
.pf-modal-actions{
  --pf-bgrid-cols:1;
  --pf-bgrid-gap:1rem;
  --pf-bgrid-basis:calc(
    (100% - (var(--pf-bgrid-cols) - 1) * var(--pf-bgrid-gap)) / var(--pf-bgrid-cols) - .05px);
}
/* --pf-bgrid-row-gap is reset to the guaranteed-invalid value so the var()
   fallback (= the column gap) applies unless a -r* modifier sets it. Without
   the reset it would INHERIT into nested grids and silently override them. */
.pf-bgrid{ --pf-bgrid-row-gap:initial;
  display:flex; flex-wrap:wrap; min-width:0;
  gap:var(--pf-bgrid-row-gap, var(--pf-bgrid-gap)) var(--pf-bgrid-gap); }
/* `> form` exists in action rows only to carry a POST; flattening it keeps
   the buttons as siblings of the other items so the row can still balance. */
.pf-bgrid > *,
.pf-bgrid > form.pf-bgrid-flat > *{ flex:1 1 var(--pf-bgrid-basis); min-width:0; }
.pf-bgrid > form.pf-bgrid-flat{ display:contents; }

/* Column count per tier. Same specificity, mobile-first source order, so the
   widest matching tier wins — exactly how the Tailwind classes they replace
   behaved. */
.pf-bgrid-1{--pf-bgrid-cols:1} .pf-bgrid-2{--pf-bgrid-cols:2} .pf-bgrid-3{--pf-bgrid-cols:3}
.pf-bgrid-4{--pf-bgrid-cols:4} .pf-bgrid-5{--pf-bgrid-cols:5} .pf-bgrid-6{--pf-bgrid-cols:6}
@media (min-width:640px){
  .pf-bgrid-sm-1{--pf-bgrid-cols:1} .pf-bgrid-sm-2{--pf-bgrid-cols:2} .pf-bgrid-sm-3{--pf-bgrid-cols:3}
  .pf-bgrid-sm-4{--pf-bgrid-cols:4} .pf-bgrid-sm-5{--pf-bgrid-cols:5} .pf-bgrid-sm-6{--pf-bgrid-cols:6}
}
@media (min-width:768px){
  .pf-bgrid-md-1{--pf-bgrid-cols:1} .pf-bgrid-md-2{--pf-bgrid-cols:2} .pf-bgrid-md-3{--pf-bgrid-cols:3}
  .pf-bgrid-md-4{--pf-bgrid-cols:4} .pf-bgrid-md-5{--pf-bgrid-cols:5} .pf-bgrid-md-6{--pf-bgrid-cols:6}
}
@media (min-width:1024px){
  .pf-bgrid-lg-1{--pf-bgrid-cols:1} .pf-bgrid-lg-2{--pf-bgrid-cols:2} .pf-bgrid-lg-3{--pf-bgrid-cols:3}
  .pf-bgrid-lg-4{--pf-bgrid-cols:4} .pf-bgrid-lg-5{--pf-bgrid-cols:5} .pf-bgrid-lg-6{--pf-bgrid-cols:6}
}
@media (min-width:1280px){
  .pf-bgrid-xl-1{--pf-bgrid-cols:1} .pf-bgrid-xl-2{--pf-bgrid-cols:2} .pf-bgrid-xl-3{--pf-bgrid-cols:3}
  .pf-bgrid-xl-4{--pf-bgrid-cols:4} .pf-bgrid-xl-5{--pf-bgrid-cols:5} .pf-bgrid-xl-6{--pf-bgrid-cols:6}
}

/* Gap scale mirrors Tailwind's (g2 = gap-2 = .5rem) so the gap survives the
   conversion unchanged. The gap MUST be owned by the component: it is a term
   in the basis, and a Tailwind `gap-*` class would set the property without
   telling the calc. */
.pf-bgrid-g1{--pf-bgrid-gap:.25rem}  .pf-bgrid-g2{--pf-bgrid-gap:.5rem}
.pf-bgrid-g3{--pf-bgrid-gap:.75rem}  .pf-bgrid-g4{--pf-bgrid-gap:1rem}
.pf-bgrid-g5{--pf-bgrid-gap:1.25rem} .pf-bgrid-g6{--pf-bgrid-gap:1.5rem}
.pf-bgrid-g8{--pf-bgrid-gap:2rem}    .pf-bgrid-g10{--pf-bgrid-gap:2.5rem}
.pf-bgrid-g12{--pf-bgrid-gap:3rem}
@media (min-width:640px){
  .pf-bgrid-sm-g2{--pf-bgrid-gap:.5rem}  .pf-bgrid-sm-g3{--pf-bgrid-gap:.75rem}
  .pf-bgrid-sm-g4{--pf-bgrid-gap:1rem}   .pf-bgrid-sm-g5{--pf-bgrid-gap:1.25rem}
  .pf-bgrid-sm-g6{--pf-bgrid-gap:1.5rem}
}
@media (min-width:768px){
  .pf-bgrid-md-g4{--pf-bgrid-gap:1rem}   .pf-bgrid-md-g5{--pf-bgrid-gap:1.25rem}
  .pf-bgrid-md-g6{--pf-bgrid-gap:1.5rem} .pf-bgrid-md-g12{--pf-bgrid-gap:3rem}
}
@media (min-width:1024px){
  .pf-bgrid-lg-g4{--pf-bgrid-gap:1rem}   .pf-bgrid-lg-g5{--pf-bgrid-gap:1.25rem}
  .pf-bgrid-lg-g6{--pf-bgrid-gap:1.5rem}
}
/* Row gap, when it differs from the column gap (Tailwind's gap-x-* / gap-y-*).
   Only the column gap is a term in the basis, so this is free to vary. */
.pf-bgrid-r2{--pf-bgrid-row-gap:.5rem}  .pf-bgrid-r3{--pf-bgrid-row-gap:.75rem}
.pf-bgrid-r4{--pf-bgrid-row-gap:1rem}   .pf-bgrid-r5{--pf-bgrid-row-gap:1.25rem}
.pf-bgrid-r6{--pf-bgrid-row-gap:1.5rem}

/* Vertical alignment mirrors the grid utilities these replace: default is
   stretch (equal heights within a row, exactly like a grid row). */
.pf-bgrid-start{ align-items:flex-start; }
.pf-bgrid-center{ align-items:center; }

/* ---- Ragged last row (.pf-bgrid-ragged) — rule 3, opted out -----------
   Rule 3 above ("an incomplete final row still fills the full grid width")
   is right for what this component was built for: buttons, toolbars, action
   rows, stat tiles. A lone Save filling the last row reads as deliberate.

   It is WRONG for a grid of content cards, and the failure is loud rather
   than subtle. A directory of 17 profiles in three columns puts two cards on
   the last row at 600px each beside fifteen at 395px, and in two columns it
   leaves one card stretched to the full 704px — the same card, the same
   content, at 1.8x the width of its neighbours, which reads as a featured
   entry that nobody featured. The product has hit this once before and
   answered it with a whole separate component (.pf-projgrid, "somebody's
   single project became a full-width hero banner"). This is the same bug in
   the same rule, so it gets the general answer instead of a second one-off:
   drop the grow, keep everything else.

   Basis, gaps, tiers, alignment and the sub-pixel wrap guard are all
   untouched — a card is exactly one column wide in every row, and a short
   final row is left-aligned under the row above it. Complete rows are
   unaffected: their bases plus gaps already sum to the full width, so there
   was never any free space for grow to absorb.

   Only for equal-size CARD grids. An action row must keep rule 3. */
.pf-bgrid-ragged > *,
.pf-bgrid-ragged > form.pf-bgrid-flat > *{ flex-grow:0; }

/* ---- Responsive action groups ----------------------------------------
   .pf-actions: desktop = inline row of naturally-sized buttons; ≤639px it
   becomes a balanced grid, so 3 actions are no longer "2 + one orphan at
   half width" — the third takes the whole final row. Two columns is the
   floor at which a button label still fits on a 320px screen; below that
   .pf-bgrid-1 would be a stack, which is what a single wide action is. */
.pf-actions{ --pf-bgrid-gap:.5rem; display:flex; flex-wrap:wrap; gap:var(--pf-bgrid-gap);
  align-items:center; }

/* ONE SIZE FOR EVERY ACTION ICON IN THE ROW — Contact, Download CV, Share.
   `pf_actions()` renders all three at w-4 h-4, and each <svg> also carries
   width="16" height="16" from Icon::box(), so three mechanisms already agree.
   This is the fourth, and the only one that survives a theme passing its own
   button class (devpro's .dp-btn) or app.css arriving while tailwind.css has
   not: a floor in absolute units, on the row itself, that no utility class and
   no missing utility class can move. min-* rather than width/height so a
   deliberately larger icon is still possible; flex:0 0 auto so a cramped
   column cannot claw it back. */
.pf-actions a > svg, .pf-actions button > svg{
  flex:0 0 auto; min-width:1rem; min-height:1rem; }
@media (max-width:639.98px){
  .pf-actions{ --pf-bgrid-cols:2; }
  .pf-actions > form{ display:contents; }
  .pf-actions > *, .pf-actions > form > *{ flex:1 1 var(--pf-bgrid-basis); min-width:0; }
  .pf-actions .pf-btn{ width:100%; justify-content:center; }
}
@media (max-width:359.98px){
  /* One column below 360px: two buttons side by side clip their labels. */
  .pf-actions{ --pf-bgrid-cols:1; }
}

/* ---- Responsive control groups (filters / searches / selects / exports)
   Desktop: natural inline row (controls keep their design widths).
   ≤639px: every control becomes a growing flex column so each row fills
   the container edge-to-edge and a wrapped last control stretches to full
   width — never a lone half-width control. Search fields take their own
   full row; forms flatten via display:contents so their controls join the
   same layout. No overflow hiding — controls genuinely fit. */
.pf-controls{ --pf-bgrid-cols:2; --pf-bgrid-gap:.5rem;
  --pf-bgrid-basis:calc(
    (100% - (var(--pf-bgrid-cols) - 1) * var(--pf-bgrid-gap)) / var(--pf-bgrid-cols) - .05px);
  display:flex; flex-wrap:wrap; gap:var(--pf-bgrid-gap); align-items:center; min-width:0; }
@media (max-width:639.98px){
  .pf-controls{ width:100%; }
  .pf-controls > form{ display:contents; }
  /* Balanced columns rather than the old `1 1 130px` min-width wrap: two
     controls per row at equal width, and a trailing odd control takes the
     whole final row instead of sitting half-width beside a gap. */
  .pf-controls > *, .pf-controls > form > *{ flex:1 1 var(--pf-bgrid-basis); min-width:0; }
  .pf-controls > .pf-search, .pf-controls > form > .pf-search{ flex:1 1 100%; }
  .pf-controls .pf-input{ width:100% !important; }   /* releases !w-* desktop widths */
  .pf-controls .pf-btn{ width:100%; justify-content:center; }
}
@media (max-width:359.98px){
  .pf-controls{ --pf-bgrid-cols:1; }
}

/* ---- Link-button components (View all / Back) ------------------------
   One component everywhere instead of ad-hoc text links. Same tokens as
   .pf-btn (radius, ring, timing); icons via icon() — never text arrows. */
.pf-link-btn{ display:inline-flex; align-items:center; gap:.3rem; font-size:.75rem; font-weight:600;
  color:var(--pf-text); background:var(--pf-surface-strong); padding:.35rem .75rem; border-radius:999px;
  white-space:nowrap; transition:background var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease); }
.pf-link-btn svg{ transition:transform var(--pf-dur-2) var(--pf-ease); }
.pf-link-btn:hover{ background:var(--pf-border); }
.pf-link-btn:hover svg{ transform:translateX(2px); }

.pf-back-btn{ display:inline-flex; align-items:center; gap:.35rem; font-size:.82rem; font-weight:600;
  color:var(--pf-muted); background:var(--pf-surface); border:1px solid var(--pf-border);
  padding:.38rem .85rem .38rem .6rem; border-radius:999px;
  transition:color var(--pf-dur-1) var(--pf-ease), border-color var(--pf-dur-1) var(--pf-ease), background var(--pf-dur-1) var(--pf-ease); }
.pf-back-btn svg{ transition:transform var(--pf-dur-2) var(--pf-ease); }
.pf-back-btn:hover{ color:var(--pf-text); border-color:var(--pf-border-strong); background:var(--pf-surface-soft); }
.pf-back-btn:hover svg{ transform:translateX(-2px); }

/* ---- Analytics design system ----------------------------------------
   One palette: brand primary for data, neutral grays for chrome, semantic
   colors ONLY for change states. No random per-widget colors. */
/* .pf-kpi is THE stat tile for the whole product — dashboard, admin overview,
   analytics, billing, promo. Before it was generalised there were three
   different treatments of "an icon, a number and a label in a card" (40px icon
   beside the number, 40px icon above it, 32px icon with a delta badge), which
   is the kind of drift that makes a product look assembled rather than
   designed. Render it through partials/stat_card.php rather than by hand.

   Vertical by design: the icon-beside-number variant is the one that ran out
   of room first, because the label then competes with the icon for the card's
   width and has to be truncated to fit. Stacked, the label owns the full card
   width at every breakpoint. */
/* ANATOMY — the icon sits on the VALUE's line, not above it.
   The first version of this component stacked icon / value / label in three
   rows. Measured, that made every tile 127px tall at every breakpoint, of
   which 44px (35%) was the icon's own row plus its margin — a row that added
   nothing horizontally, because a 28px glyph alone on a 312px line is just
   whitespace with a square in it. Moving the icon beside the number reclaims
   that row outright and, because the label then spans the card's FULL width,
   it also keeps the natural wrapping that stacking was introduced for.
   Result: ~76px, no change to the number's size — the fix is structural, not
   typographic.

   Height is content-driven; there is no min-height. Equal heights within a
   row come from .pf-bgrid's default stretch, which is where that concern
   belongs — a min-height here would force short rows tall as well. */
.pf-kpi{ display:flex; flex-direction:column; gap:.2rem; padding:.85rem 1rem; }
.pf-kpi-head{ display:flex; align-items:center; gap:.55rem; min-width:0; }
.pf-kpi-ico{ display:grid; place-items:center; flex:0 0 auto; width:1.75rem; height:1.75rem;
  border-radius:9px; background:rgb(var(--pf-primary-rgb) / 10%);
  color:var(--pf-primary-ink); }
/* Sized to sit level with the icon: any larger and the number, not the icon,
   sets the row height and the saving is given straight back. */
/* A NUMBER never breaks. `overflow-wrap:anywhere` used to apply here, and in a
   two-up tile grid at 320px it split "5,041" across two lines as "5,04" and
   "1" — which does not read as five thousand, it reads as a bug. Numbers get
   `nowrap` and shed a type step instead when the column is narrow; the
   variant below, whose value is a NAME, is the one that wants to break. */
.pf-kpi-value{ font-size:clamp(1.15rem, 4.2vw, 1.4rem); font-weight:800;
  font-variant-numeric:tabular-nums; white-space:nowrap;
  color:var(--pf-text); line-height:1.15; min-width:0; }
/* A tile whose value is a NAME rather than a number ("Afghanistan", "Kabul" —
   see stat_card's $text).
   The row above is sized for four or five digits sitting beside an icon. A
   country name in that space, in a phone-width column, breaks mid-word into
   "Afgha / nista / n". So the name gets a line of its own — icon and count
   chip first, the name across the full width beneath — and drops one type step.
   Same tile, same tokens, one reflow. */
.pf-kpi-text .pf-kpi-head{ flex-wrap:wrap; row-gap:.3rem; }
.pf-kpi-text .pf-kpi-ico{ order:1; }
.pf-kpi-text .pf-kpi-trail{ order:2; }
.pf-kpi-value-text{ order:3; flex:1 0 100%;
  /* Overrides the number rule above: a name DOES wrap, at spaces first and
     anywhere only if a single word still will not fit. */
  font-size:1.05rem; line-height:1.25; white-space:normal; overflow-wrap:break-word; }
/* Trailing slot for a delta/status pill — pushed right so it reads as an
   annotation on the number rather than a second value. */
.pf-kpi-trail{ margin-left:auto; flex:0 0 auto; }
/* Wraps rather than truncates. A stat label is two or three words chosen to be
   read; "Profile vie…" is a worse outcome at every width than a second line,
   and .pf-bgrid already equalises heights across a row so wrapping one card
   costs nothing. */
.pf-kpi-label{ font-size:.75rem; font-weight:600; color:var(--pf-muted);
  overflow-wrap:anywhere; text-wrap:balance; }

/* A 2×2 KPI block whose four cards match in height, not just two-by-two.
   .pf-bgrid is flexbox, and flexbox only equalises within a flex LINE — so on
   a narrow phone where "Profile views · 30d" wraps to two lines but
   "Link clicks · 30d" does not, the top row stood 24px taller than the bottom.
   Swapping to grid for that range lets grid-auto-rows:1fr level all four: in
   an auto-height grid every implicit row resolves to the tallest, so nothing
   is padded on widths where no label wraps. Opt-in, because the wrapping
   behaviour is right for most .pf-bgrid rows. */
@media (max-width:479.98px){
  .pf-bgrid.pf-kpi-grid{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr));
    grid-auto-rows:1fr; }
  /* At 360px the icon, the value and the delta pill together came to ~117px in
     a ~120px card, so the only shrinkable item — the value — took the hit and
     "15" broke into "1" / "5". A split number reads as a rendering fault.
     Tightening the gap buys the few pixels that keep the row intact, and
     flex-wrap is the safety net: if a longer value ever does run out of room
     the PILL drops to its own line instead, because flex wrapping happens
     before an item is squeezed below min-content. .pf-kpi-value keeps
     overflow-wrap:anywhere as the final fallback for a genuinely huge number. */
  .pf-kpi-grid .pf-kpi-head{ flex-wrap:wrap; gap:.4rem; }
  /* 8px back from the side padding. At 360px the row needed 120.8px and had
     118.0 — this closes a 2.8px gap and keeps icon, value and pill on one
     line. Vertical padding is untouched, so the cards do not get shorter. */
  .pf-kpi-grid .pf-kpi{ padding-inline:.75rem; }
}
.pf-delta{ display:inline-flex; align-items:center; gap:.2rem; font-size:.7rem; font-weight:700;
  padding:.1rem .45rem; border-radius:999px; white-space:nowrap; }
.pf-delta-up{ background:#ecfdf5; color:#047857; }
.pf-delta-down{ background:#fef2f2; color:#b91c1c; }
.pf-delta-flat{ background:var(--pf-surface-strong); color:var(--pf-muted); }
.pf-rank-row{ display:flex; align-items:center; gap:.6rem; font-size:.85rem; min-width:0; }
.pf-rank-n{ width:1.1rem; text-align:right; font-size:.72rem; color:var(--pf-muted); font-variant-numeric:tabular-nums; flex:0 0 auto; }
.pf-rank-label{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--pf-text); }
.pf-rank-value{ font-size:.75rem; color:var(--pf-muted); font-variant-numeric:tabular-nums; flex:0 0 auto; }
.pf-rank-bar{ width:3.5rem; flex:0 0 auto; height:.35rem; border-radius:999px; background:#eef0f3; overflow:hidden; }
.pf-rank-bar > span{ display:block; height:100%; border-radius:999px; background:var(--pf-primary); }

/* Search input: icon pinned left, text never overlaps, mobile-safe width.
   Markup: <label class="pf-search"><span class="pf-search-ico">icon</span><input class="pf-input"></label> */
.pf-search{ position:relative; display:block; min-width:0; }
.pf-search .pf-search-ico{ position:absolute; left:.8rem; top:50%; transform:translateY(-50%);
  display:inline-flex; color:#9ca3af; pointer-events:none; z-index:1; }
.pf-search .pf-input{ padding-left:2.4rem; }

/* Tabs / segmented filters (reusable: messages, notifications, admin lists) */
/* `flex-wrap:wrap` is load-bearing, not decoration: a bar wider than its
   column wraps to a second row. Without it the bar keeps one row and relies on
   overflow-x, and a wide strip (the analytics metric switcher in a 448px
   content column at 768px) puts a tab outside its own box. Only
   `.pf-tabs-icons` opts out, because collapsing to icons is what makes one row
   fit in the first place. */
.pf-tabs{ display:flex; align-items:center; flex-wrap:wrap; gap:.25rem; padding:.25rem;
  background:var(--pf-surface-strong);
  border-radius:12px; width:max-content; max-width:100%; overflow-x:auto; }
.pf-tab{ display:inline-flex; align-items:center; gap:.4rem; font-size:.82rem; font-weight:600;
  color:var(--pf-muted); padding:.4rem .85rem; border-radius:9px; white-space:nowrap; transition:var(--pf-dur-1); }
.pf-tab:hover{ color:var(--pf-text); background:var(--pf-surface-soft); }
.pf-tab:focus-visible{ outline:2px solid var(--pf-primary); outline-offset:-2px; }
/* AFTER :hover on purpose. `.pf-tab.active` and `.pf-tab:hover` are both
   specificity 0-2-0, so the selected pill only keeps its white fill under the
   pointer by being declared later. (It was not: a second, underline-flavoured
   copy of this component further down the file re-declared `.pf-tab:hover`
   with a grey fill, and hovering the selected tab greyed it out. That copy is
   gone — there is one tab component.) */
.pf-tab.active,.pf-tab.active:hover{ background:var(--pf-surface); color:var(--pf-text);
  box-shadow:var(--pf-shadow-sm); }
.pf-tab .pf-tab-count{ font-size:.68rem; font-weight:700; padding:.05rem .4rem; border-radius:999px;
  background:var(--pf-border); color:var(--pf-muted); }
.pf-tab.active .pf-tab-count{ background:var(--pf-ink); color:#fff; }

/* Segmented tabs fill the container evenly on phones: 4 range tabs become
   2×2, 3 folder tabs become 2 + one full-width — the same balanced rule as
   .pf-bgrid, driven by the same basis formula instead of a hand-picked 40%
   flex-basis. Two deliberate details:
     · Placed AFTER the base rule: media queries carry no extra weight, so
       `width:100%` has to out-order the base `width:max-content`, or the bar
       stays shrink-to-fit and never fills the row.
     · `:not(.pf-tabs-icons)` protects the compact icon bar below, which is
       deliberately a content-sized SCROLLING bar (icon-only inactive tabs) —
       wrapping it into balanced rows would undo that design. `.pf-tabs-stick`
       is a pinned bar and always carries `.pf-tabs-icons` too, so ONE
       `:not()` covers both. Do not add a second: `:not()` contributes its
       argument's specificity, so `:not(.pf-tabs-icons):not(.pf-tabs-stick)`
       is 0-3-0 and out-weighs `.pf-tabs.pf-tabs-compact` (0-2-0) instead of
       merely preceding it — which put the analytics range picker back into
       the 2×2 grid the compact opt-out exists to prevent. */
@media (max-width:479.98px){
  .pf-tabs:not(.pf-tabs-icons){ --pf-bgrid-cols:2; --pf-bgrid-gap:.25rem;
    --pf-bgrid-basis:calc(
      (100% - (var(--pf-bgrid-cols) - 1) * var(--pf-bgrid-gap)) / var(--pf-bgrid-cols) - .05px);
    width:100%; flex-wrap:wrap; }
  .pf-tabs:not(.pf-tabs-icons) .pf-tab{
    flex:1 1 var(--pf-bgrid-basis); justify-content:center; min-width:0; }

  /* Opt-out: keep ONE row. The 2×2 rule above is right for word labels
     ("Unread", "Archived") which cannot shrink, but a date-range bar is four
     to six three-character labels — stacking those into three rows costs more
     vertical space than the chart it filters. Placed after the wrap rule and
     matched at the same specificity (.pf-tabs.pf-tabs-compact = 0-2-0), so
     source order is what wins.
       flex:1 1 0  — every tab takes an equal share of the row rather than a
                     share of its own content width, so the control is evenly
                     divided whether the plan shows four options or six.
       min-width:0 — without it flex items refuse to shrink below their label
                     and the bar would overflow instead of dividing. */
  .pf-tabs.pf-tabs-compact{ flex-wrap:nowrap; gap:.125rem; width:100%; }
  .pf-tabs.pf-tabs-compact .pf-tab{
    flex:1 1 0; min-width:0; justify-content:center;
    gap:.2rem; padding:.4rem .3rem; font-size:.75rem; }
}

/* Pins a .pf-tabs bar under the dashboard header (theme editor). Carries the
   sticky geometry only — no surface of its own, so the segmented pill is the
   one visible thing and the bar sits straight on the page background.
   Two load-bearing numbers, both tied to layout_dashboard.php:
     top   = the header's h-16 (4rem) — anything less floats it over the header.
     z-index < the header's z-30: the header is sticky + z-30, so it opens a
     stacking context its dropdowns can never escape (their z-50 is scoped to
     it). At z-30 this bar, being later in the DOM, painted OVER the header
     and its notification menu; it must stay below. */
.pf-tabs.pf-tabs-stick{ position:sticky; top:4rem; z-index:20; }

/* ── .pf-tabs-icons — THE mobile shape of a segmented tab bar ───────────────
   Inactive tabs collapse to their icon; only the selected one keeps its label.

   This started as one page's rule (the Appearance editor's pinned bar) and is
   now the shared pattern, because every tab strip in the signed-in product has
   the same problem in the same place. Three word labels — "Inbox / Archived /
   All", "All / Unread / Read" — cannot shrink, so the balanced-wrap rule above
   turned each bar into two full-width rows of cards; an inbox opened on a
   phone spent ~160px of the first screen on four rows of filter chrome before
   a single message. Icon-collapsed, the same two bars are ~80px and read as
   one control instead of a list.

   TWO BANDS, not one — the rule from UI_UX_SYSTEM.md's "shell subtracts
   320px": inside the dashboard/admin shell a 768px viewport gives <main> only
   ~384px, LESS than a 640px phone gets, because the phone has no rail. So the
   collapse fires below 640 and again between 768 and 1024, and the 640–767
   band (a phone-shaped viewport with no rail) keeps its full labels.

   Requirements this encodes:
     · the label is visually hidden, never display:none — the icons carry no
       text of their own, so dropping it from the a11y tree would leave the
       control with no accessible name. An absolutely positioned child is not a
       flex item, so .pf-tab's gap collapses with it and the icon centres.
     · 2.75rem min box = a 44×44 touch target for an icon-only tab.
     · counts STAY visible. "3 unread" is the reason somebody looks at the bar;
       a `.pf-tab-label` nested inside a badge (e.g. "failed") still folds.
     · content-sized tabs + nowrap: the selected pill grows to exactly its
       label, the bar never wraps into rows, and if it still cannot fit it
       scrolls INSIDE itself (base .pf-tabs is overflow-x:auto) rather than
       pushing the page sideways. The BAR is content-sized too (base
       width:max-content), so two bars on one flex row — folder + read filter
       on the inbox — sit side by side wherever they fit and wrap to a second
       row only when they do not. Forcing width:100% here instead left a
       200px-wide control inside a 340px grey track, twice over.
     · .pf-tab already carries `transition:var(--pf-dur-1)`, so a selection
       change eases between the two widths instead of snapping. */
@media (max-width:639.98px), (min-width:768px) and (max-width:1023.98px){
  .pf-tabs.pf-tabs-icons{ max-width:100%; flex-wrap:nowrap; gap:.2rem; }
  .pf-tabs.pf-tabs-icons .pf-tab{
    flex:0 1 auto; width:auto; min-width:2.75rem; min-height:2.75rem;
    justify-content:center; padding:.4rem .7rem; }
  .pf-tabs.pf-tabs-icons .pf-tab > svg{ flex:0 0 auto; }
  .pf-tabs.pf-tabs-icons .pf-tab:not(.active) .pf-tab-label{
    position:absolute; width:1px; height:1px; margin:0; padding:0; border:0;
    overflow:hidden; white-space:nowrap; clip-path:inset(50%); }
  /* The active label is the one thing allowed to lose characters, and only
     when even the icon-collapsed bar is too wide for the column. */
  .pf-tabs.pf-tabs-icons .pf-tab.active .pf-tab-label{
    min-width:0; overflow:hidden; text-overflow:ellipsis; }
}

@media (max-width:639.98px){
  /* At rest the pinned bar spans the content column, so its edges line up with
     the template cards below (base .pf-tabs is width:max-content, which would
     size it to the tabs instead). */
  .pf-tabs.pf-tabs-stick{ width:auto; max-width:none; }

  /* Pinned: bleed out through <main>'s 1.25rem inline padding to the viewport
     edges. The inline padding absorbs that bleed PLUS the bar's own .25rem
     inset (1.25 + .25 = 1.5rem), so the tabs sit at the same x in both states
     and nothing slides sideways as it pins. Only inline margin/padding change
     — the block axis, and so mb-5 and the bar's height, are untouched, so
     there is no vertical shift either.
     .is-stuck comes from app.js: a sticky element cannot detect its own stuck
     state in CSS (there is no :stuck), so it has to be observed. */
  .pf-tabs.pf-tabs-stick.is-stuck{
    margin-inline:-1.25rem; padding-inline:1.5rem; border-radius:0; }
}

/* Gradient text — brand moment only (hero keyword, logo wordmark). */
.pf-gradient-text{ background:var(--pf-grad);
  -webkit-background-clip:text; background-clip:text; color:transparent; }

/* ── BRAND MARK ──────────────────────────────────────────────────────────────
   Every logo on the platform comes from brand_mark() (app/lib/helpers.php) and
   is sized by exactly one of these classes. The images carry their intrinsic
   width/height, so `width:auto` + a fixed height is what keeps the aspect ratio
   honest while the browser still reserves the correct box before load.

   The lockup is SVG, viewBox 1192×252 (≈4.73:1) — the same proportions as the
   960×204 PNG it replaced, cropped to the drawn artwork so it drops in without
   resizing anything. Heights are set rather than widths because the mark has
   to line up optically with the text and buttons beside it.

   SIZE IS A RATIO OF THE BAR IT SITS IN, not a fixed number picked by eye.
   ClickUp is the reference: their header runs a logo at roughly a third of the
   bar's height, which is what stops a wide lockup from crowding the nav links
   beside it. --pf-brand-ratio holds that third; each slot multiplies it by its
   own bar height, so a bar that changes height takes the logo with it.

     public header   4rem  → 22px      (h-16, layout_public)
     mobile navhdr   4rem  → 22px      (.pf-navhdr, dashboard + admin drawer)
     auth screens    —     → 30px      no bar; sized as the page's focal mark
     icon rail       40px  → 24px      box, not bar (see .pf-mrail-logo)

   The mark carries its own colour, so it always sits directly on the surface —
   there is no plate behind it. The gradient tile the old sparkles glyph needed
   would fight the logo's own gradient, not frame it. */
:root{ --pf-brand-ratio: .34; --pf-hdr-h: 4rem; }

.pf-brand-word{ display:block; width:auto; flex:0 0 auto;
  height:calc(var(--pf-hdr-h) * var(--pf-brand-ratio)); }
.pf-brand-word-lg{ display:block; width:auto; height:30px; flex:0 0 auto; }
.pf-brand-icon{ display:block; width:auto; flex:0 0 auto;
  height:calc(var(--pf-hdr-h) * var(--pf-brand-ratio)); }

/* The anchor wrapping a mark. inline-flex + centre kills the baseline gap an
   inline-block leaves under a replaced element — that descender space was
   pushing the footer and header lockups a couple of pixels off centre. */
.pf-brand-link{ display:inline-flex; align-items:center; }

@media (max-width:479.98px){
  /* Only the wide lockup steps down on narrow screens — at 4.7:1 it is what
     runs out of room beside the menu button. The square icon does not. */
  .pf-brand-word{ height:20px; }
}

/* Animated entrance */
@keyframes pf-rise{ from{opacity:0; transform:translateY(14px);} to{opacity:1; transform:none;} }
.pf-rise{ animation:pf-rise .6s cubic-bezier(.2,.7,.2,1) both; }

/* Skill bar */
.pf-skillbar{ height:8px; border-radius:999px; background:#eef0f3; overflow:hidden; }
.pf-skillbar > span{ display:block; height:100%; border-radius:999px;
  /* The official ramp, so a progress bar and the hero wordmark are the same
     four colours in the same order. No text sits on it, so it takes the exact
     values rather than the darkened rendering. The gradient spans the FILLED
     width, which means a bar always shows the whole sweep however full it is. */
  background:var(--pf-grad); }

/* Progress ring helper */
.pf-progress{ background:conic-gradient(var(--pf-brand-violet) calc(var(--v)*1%), #eef0f3 0); }

/* Floating tooltip (icon rail) */
.pf-sbtip{ position:fixed; z-index:120; background:#0f172a; color:#fff; font-size:.75rem; font-weight:600;
  padding:.3rem .6rem; border-radius:7px; pointer-events:none; white-space:nowrap; opacity:0;
  box-shadow:0 6px 18px rgba(0,0,0,.25); transition:opacity .1s var(--pf-ease); }
.pf-sbtip.on{ opacity:1; }

/* Body scroll lock while a drawer is open (class set by app.js) */
@media (max-width:767.98px){
  html.pf-drawer-open, html.pf-drawer-open body{ overflow:hidden; overscroll-behavior:none; }
}

/* Drawer overlay (fading, mobile only) */
.pf-overlay{ display:none; position:fixed; inset:0; background:rgba(15,23,42,.45);
  z-index:45; cursor:pointer; opacity:0; pointer-events:none; transition:opacity var(--pf-dur-3); }
@media (max-width:767.98px){
  .pf-overlay{ display:block; }
  .pf-overlay.visible{ opacity:1; pointer-events:auto; }
}

/* ==================== TWO-LEVEL NAVIGATION SHELL ========================
   ONE nav system for BOTH the user dashboard and the admin panel
   (partials/nav_shell.php, data from app/lib/Nav.php — only data differs).
     .pf-shellnav  sticky wrapper holding both nav panels; the mobile drawer
     .pf-mrail     72px icon-only primary rail (labelled rows in the drawer)
     .pf-ctx       contextual sidebar for the active module (collapsible)
   Motion contract: 180ms ease on hover/active surfaces and panel width. */
.pf-shellnav{ display:flex; position:sticky; top:0; z-index:40; height:100vh; height:100dvh;
  flex:0 0 auto; background:var(--pf-surface); }
/* Desktop: the mobile-only full-width header is hidden and the body wrapper
   dissolves (display:contents) so the rail + panel stay DIRECT flex children of
   the shell — desktop structure is byte-for-byte unchanged. */
.pf-navhdr{ display:none; }
.pf-navbody{ display:contents; }

/* --- Primary rail -----------------------------------------------------
   Desktop: 72px icon column. Mobile drawer: full-width labelled rows
   (.pf-mrail-caption/.pf-mrail-more become visible) — same markup. */
.pf-mrail{ width:72px; flex:0 0 72px; display:flex; flex-direction:column; align-items:center;
  padding:.75rem 0; border-right:1px solid #f3f4f6; background:var(--pf-surface); }
.pf-mrail-head{ display:flex; align-items:center; justify-content:center; flex:0 0 auto;
  margin-bottom:.9rem; }
/* No plate: the mark supplies its own colour, so the 40px box is just the hit
   area. It used to be a gradient tile because the sparkles glyph was a white
   line icon that needed one. */
.pf-mrail-logo{ display:grid; place-items:center; width:40px; height:40px; border-radius:12px;
  flex:0 0 auto; }
/* Sized off the 40px box rather than the shared bar ratio: this is the only
   slot where the mark stands alone with nothing beside it to balance. */
.pf-mrail-logo .pf-brand-icon{ height:24px; }
.pf-mrail-brand{ display:none; font-weight:800; letter-spacing:-.01em; min-width:0;
  align-items:center; gap:.5rem; }
.pf-mrail-chip{ font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.08em;
  background:var(--pf-surface-strong); color:var(--pf-muted); padding:.1rem .4rem; border-radius:5px; }
.pf-mrail-close{ display:none; }
.pf-mrail-nav{ display:flex; flex-direction:column; align-items:center; gap:.3rem;
  flex:1; min-height:0; overflow-y:auto; scrollbar-width:none; width:100%; padding:.1rem 0; }
.pf-mrail-nav::-webkit-scrollbar{ display:none; }
.pf-mrail-link{ position:relative; display:flex; align-items:center; justify-content:center;
  width:44px; height:44px; border-radius:12px; color:var(--pf-muted); gap:.65rem;
  transition:background-color var(--pf-dur-2) var(--pf-ease), color var(--pf-dur-2) var(--pf-ease); }
.pf-mrail-ico{ display:grid; place-items:center; flex:0 0 auto; }
.pf-mrail-link:hover{ background:var(--pf-surface-soft); color:var(--pf-text); }
/* .open  = this section's submenu panel is currently showing (soft container).
   .active = the current page lives in this section — a filled ink pill, the
   primary state. Both can appear on different icons at once (preview another
   panel without navigating): the ink pill always marks the real page. No
   detached edge bar — the connected icon background carries the state. */
.pf-mrail-link.open{ background:var(--pf-surface-strong); color:var(--pf-text); }
.pf-mrail-link.active,
.pf-mrail-link.active:hover{ background:var(--pf-ink); color:#fff; }
.pf-mrail-dot{ position:absolute; top:9px; right:9px; width:8px; height:8px; border-radius:50%;
  background:var(--pf-danger); box-shadow:0 0 0 2px var(--pf-surface); }
/* Rail section captions: hidden — the rail is icon-only on every width
   (the active section's name shows in the contextual panel header instead). */
.pf-mrail-caption, .pf-mrail-more{ display:none; }

/* --- Contextual sidebar ---------------------------------------------- */
.pf-ctx{ width:248px; flex:0 0 auto; overflow:hidden; border-right:1px solid #f3f4f6;
  background:var(--pf-surface); }
.pf-ctx-inner{ display:flex; flex-direction:column; width:248px; height:100%; }
.pf-ctx-head{ height:4rem; flex:0 0 auto; display:flex; align-items:center; gap:.5rem;
  padding:0 .8rem 0 1rem; border-bottom:1px solid #f3f4f6; }
.pf-ctx-title{ flex:1; min-width:0; font-size:.95rem; font-weight:700; letter-spacing:-.01em;
  color:var(--pf-text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.pf-ctx-collapse{ display:none; place-items:center; width:26px; height:26px; border-radius:7px;
  border:1px solid #e5e7eb; background:#f9fafb; color:#9ca3af; cursor:pointer;
  transition:background-color var(--pf-dur-2) var(--pf-ease), color var(--pf-dur-2) var(--pf-ease); }
.pf-ctx-collapse:hover{ background:#eef2f7; color:#374151; }
.pf-ctx-nav{ flex:1; min-height:0; overflow-y:auto; padding:.75rem;
  scrollbar-gutter:stable; scrollbar-width:thin; scrollbar-color:#e2e8f0 transparent; }
.pf-ctx-nav::-webkit-scrollbar{ width:4px; }
.pf-ctx-nav::-webkit-scrollbar-track{ background:transparent; }
.pf-ctx-nav::-webkit-scrollbar-thumb{ background:#e2e8f0; border-radius:999px; }
.pf-ctx-nav:hover::-webkit-scrollbar-thumb{ background:#cbd5e1; }
.pf-ctx-label{ font-size:.66rem; font-weight:700; text-transform:uppercase; letter-spacing:.1em;
  color:var(--pf-muted); padding:.9rem .6rem .35rem; }
/* The first group's label sits right under the panel head — drop the inter-group
   top padding so it doesn't open with a gap. */
.pf-ctx-nav > .pf-ctx-label:first-child{ padding-top:.15rem; }
.pf-ctx-link{ display:flex; align-items:center; gap:.6rem; padding:.42rem .6rem; min-height:38px;
  border-radius:12px; color:var(--pf-muted); font-size:.875rem; font-weight:500;
  transition:background-color var(--pf-dur-2) var(--pf-ease), color var(--pf-dur-2) var(--pf-ease); }
.pf-ctx-link:hover{ background:var(--pf-surface-soft); color:var(--pf-text); }
.pf-ctx-link.active{ background:var(--pf-surface-strong); color:var(--pf-text); font-weight:600;
  box-shadow:0 1px 2px rgba(17,19,24,.05); }
.pf-ctx-badge{ font-size:10px; font-weight:700; background:var(--pf-danger); color:#fff;
  border-radius:999px; padding:.1rem .4rem; min-width:1.1rem; text-align:center; }
/* Hidden submenu panels (only the selected section's panel is shown). */
.pf-ctx-nav[hidden]{ display:none; }

/* Keyboard focus — same ink ring on every nav affordance. */
.pf-mrail-link:focus-visible, .pf-mrail-logo:focus-visible,
.pf-ctx-link:focus-visible, .pf-ctx-collapse:focus-visible,
.pf-ctx-expand:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }

/* Header affordance that re-opens a collapsed contextual panel (desktop). */
.pf-ctx-expand{ display:none; place-items:center; width:34px; height:34px; border-radius:10px;
  color:var(--pf-muted); transition:background-color var(--pf-dur-2) var(--pf-ease), color var(--pf-dur-2) var(--pf-ease); }
.pf-ctx-expand:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }

/* Desktop: the contextual panel collapses to a pure icon-rail workspace.
   Width animates (contents keep their fixed 248px via .pf-ctx-inner). */
@media (min-width:768px){
  .pf-ctx{ transition:width var(--pf-dur-2) var(--pf-ease); }
  .pf-ctx-collapse{ display:grid; }
  html.pf-ctx-collapsed .pf-ctx{ width:0; border-right-width:0; }
  html.pf-ctx-collapsed .pf-ctx-expand{ display:grid; }
}

/* Mobile: a TRUE full-screen navigation surface (like the public site's
   .pf-navsheet) — never a narrow side-drawer. Full-width header on top; below
   it the SAME two columns as desktop (icon rail + active submenu, both visible
   at once). NO drill-down, NO back arrow, NO separate submenu screen: tapping a
   rail icon just swaps the panel (JS). Only the rail and submenu scroll; the
   header stays fixed. Covers the whole viewport so no page strip shows beside. */
@media (max-width:767.98px){
  .pf-shellnav{ position:fixed; inset:0; z-index:100; width:100vw; height:100dvh;
    flex-direction:column; background:var(--pf-surface);
    transform:translateX(-100%); visibility:hidden;
    transition:transform var(--pf-dur-3) var(--pf-ease-io), visibility 0s linear var(--pf-dur-3); }
  .pf-shellnav.open{ transform:translateX(0); visibility:visible;
    transition:transform var(--pf-dur-3) var(--pf-ease-io); }

  /* Full-width menu header (brand + role chip + close) */
  .pf-navhdr{ display:flex; align-items:center; justify-content:space-between; gap:.75rem;
    flex:0 0 auto; height:4rem; padding:0 .75rem 0 1rem; border-bottom:1px solid #f3f4f6; }
  .pf-navhdr-brand{ display:flex; align-items:center; gap:.6rem; min-width:0; min-height:44px;
    font-weight:800; letter-spacing:-.01em; color:var(--pf-text); }
  /* Inherits the shared bar ratio — .pf-navhdr is 4rem, the same as the
     public header, so the two read at identical size. */
  .pf-navhdr-logo{ display:grid; place-items:center; width:2rem; height:2rem; flex:0 0 auto; }
  .pf-navhdr .pf-btn-icon{ width:44px; min-height:44px; flex:0 0 auto; }

  /* The two columns fill the remaining height; each scrolls on its own. The
     rail keeps its desktop 72px width; the active state is the icon's own ink
     pill (no edge bar), so nothing clips at the viewport edge. */
  .pf-navbody{ display:flex; flex:1 1 auto; min-height:0; }
  .pf-mrail-head{ display:none; }               /* brand now lives in .pf-navhdr */
  .pf-ctx{ flex:1 1 auto; width:auto; min-width:0; border-right:0; }
  .pf-ctx-inner{ width:100%; }
  .pf-ctx-head{ padding-left:.9rem; }
}

/* ==================== MENU SURFACES (dropdowns / popovers) ==============
   ONE surface for every navigation menu — header notifications, the account
   menu, search results. They used to each carry their own ad-hoc
   bg/border/shadow utilities, which drifted apart. Same radius, border, shadow,
   spacing, entrance and hover language as the rest of the system. */
.pf-menu{ background:var(--pf-surface); border:1px solid var(--pf-border);
  border-radius:var(--pf-radius-lg); overflow:hidden; padding:.375rem;
  box-shadow:0 1px 2px rgba(17,19,24,.04), 0 12px 32px -12px rgba(17,19,24,.20);
  transform-origin:top right;
  animation:pf-menu-in .16s cubic-bezier(.2,.7,.2,1); }
@keyframes pf-menu-in{ from{ opacity:0; transform:translateY(-4px) scale(.98); } }

/* Section head inside a menu (e.g. "Notifications" + Mark all read) */
.pf-menu-head{ display:flex; align-items:center; justify-content:space-between; gap:.5rem;
  padding:.5rem .625rem .625rem; }
.pf-menu-title{ font-size:.8125rem; font-weight:700; color:var(--pf-text); }

/* A row: the one hover language for every menu item. */
.pf-menu-item{ display:flex; align-items:center; gap:.625rem; width:100%;
  padding:.5rem .625rem; border-radius:9px; font-size:.875rem; font-weight:500;
  color:var(--pf-text); text-align:left;
  transition:background-color var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease); }
.pf-menu-item:hover{ background:var(--pf-surface-strong); }
.pf-menu-item svg{ color:var(--pf-muted); flex:0 0 auto; }
.pf-menu-item:hover svg{ color:var(--pf-text); }
.pf-menu-item--danger{ color:var(--pf-danger); }
.pf-menu-item--danger svg{ color:var(--pf-danger); }
.pf-menu-item--danger:hover{ background:#FEF2F2; }

.pf-menu-sep{ height:1px; margin:.375rem -.375rem; background:var(--pf-border); }

/* Identity block at the top of the account menu */
.pf-menu-id{ padding:.625rem; }
.pf-menu-id-name{ font-size:.875rem; font-weight:600; color:var(--pf-text); }
.pf-menu-id-sub{ font-size:.75rem; color:var(--pf-muted); }

/* Scrollable body (notification list, search results) */
.pf-menu-body{ max-height:20rem; overflow-y:auto; overscroll-behavior:contain; }
.pf-menu-foot{ padding-top:.375rem; margin-top:.375rem; border-top:1px solid var(--pf-border); }
.pf-menu-empty{ padding:1.75rem .625rem; text-align:center; font-size:.8125rem;
  color:var(--pf-muted); }
@media (prefers-reduced-motion:reduce){ .pf-menu{ animation:none; } }

/* Header dropdowns (notifications / account) on mobile: anchor to the VIEWPORT,
   not to the trigger — a trigger sitting mid-header would otherwise push a
   right-0 panel off the left edge. Fixed below the 4rem header with a safe
   ~12px gutter each side; width tracks the viewport, capped at the panel's own
   desktop max-width (max-w-[20rem]/[15rem] on the element). Right-aligned, and
   on narrow screens the equal gutters read as centred — never clipped either
   side. Desktop keeps the Tailwind `absolute right-0` beside the icon. */
@media (max-width:639.98px){
  .pf-hdr-menu{ position:fixed !important; top:calc(4rem + .5rem) !important;
    left:auto !important; right:.75rem !important; margin-top:0 !important;
    width:calc(100vw - 1.5rem) !important; z-index:90 !important;
    transform-origin:top center; }
}

/* ---- Toasts ---- */
.pf-toasts{ position:fixed; top:1rem; right:1rem; z-index:120; display:flex; flex-direction:column;
  gap:.6rem; width:min(92vw,380px); }
.pf-toast{ display:flex; align-items:flex-start; gap:.6rem; background:var(--pf-surface);
  border:1px solid var(--pf-border); border-left-width:4px; border-radius:12px; padding:.8rem .9rem;
  box-shadow:0 10px 30px rgba(16,24,40,.12); font-size:.875rem; color:var(--pf-text);
  animation:pf-toast-in .35s cubic-bezier(.2,.7,.2,1) both; }
.pf-toast.hide{ animation:pf-toast-out .3s ease forwards; }
.pf-toast-icon{ flex-shrink:0; margin-top:1px; }
/* Dismiss: small glyph, finger-sized target (negative margin keeps the toast's
   own padding from growing around it). */
.pf-toast-close{ margin-left:auto; margin-block:-.6rem; margin-right:-.3rem;
  display:inline-grid; place-items:center; min-width:2.5rem; min-height:2.5rem;
  color:var(--pf-muted); cursor:pointer; line-height:1; border-radius:8px; }
.pf-toast-close:hover{ background:#f1f5f9; color:var(--pf-text); }
.pf-toast--success{ border-left-color:var(--pf-success); } .pf-toast--success .pf-toast-icon{ color:var(--pf-success); }
.pf-toast--error{   border-left-color:var(--pf-danger);  } .pf-toast--error .pf-toast-icon{   color:var(--pf-danger); }
.pf-toast--info{    border-left-color:var(--pf-info);    } .pf-toast--info .pf-toast-icon{    color:var(--pf-info); }
.pf-toast--warning{ border-left-color:var(--pf-warning); } .pf-toast--warning .pf-toast-icon{ color:var(--pf-warning); }
@keyframes pf-toast-in{ from{opacity:0; transform:translateX(20px);} to{opacity:1; transform:none;} }
@keyframes pf-toast-out{ to{opacity:0; transform:translateX(20px);} }

/* ---- Modal (custom confirm dialog) ---- */
.pf-modal{ position:fixed; inset:0; z-index:130; display:flex; align-items:center; justify-content:center;
  padding:1rem; opacity:0; pointer-events:none; transition:opacity var(--pf-dur-2) var(--pf-ease); }
.pf-modal.open{ opacity:1; pointer-events:auto; }
.pf-modal-backdrop{ position:absolute; inset:0; background:rgba(15,23,42,.55); backdrop-filter:blur(2px); }
.pf-modal-card{ position:relative; background:var(--pf-surface); border-radius:16px; width:min(94vw,440px);
  box-shadow:0 24px 60px rgba(16,24,40,.28); padding:1.5rem; transform:translateY(12px) scale(.98);
  transition:transform var(--pf-dur-2) var(--pf-ease); }
.pf-modal.open .pf-modal-card{ transform:none; }
.pf-modal-icon{ width:3rem; height:3rem; border-radius:12px; display:grid; place-items:center; margin-bottom:1rem; }
.pf-modal-icon--danger{ background:#fef2f2; color:var(--pf-danger); }
.pf-modal-icon--primary{ background:var(--pf-primary-soft); color:var(--pf-primary-ink); }
.pf-modal-title{ font-size:1.1rem; font-weight:700; color:var(--pf-text); }
.pf-modal-body{ font-size:.9rem; color:var(--pf-muted); margin-top:.35rem; line-height:1.5; }
/* Modal actions follow the balanced rule: the dialog is at most 440px wide, so
   a right-aligned pair at natural widths left dead space on one side and gave
   the two buttons different hit areas. Now two actions split the card evenly
   and a lone action takes the full width — same flex-basis + grow mechanic as
   .pf-bgrid, so a third action wraps to a full-width final row rather than
   orphaning. */
.pf-modal-actions{ --pf-bgrid-cols:2; --pf-bgrid-gap:.6rem;
  display:flex; flex-wrap:wrap; gap:var(--pf-bgrid-gap); justify-content:flex-end;
  margin-top:1.4rem; }
.pf-modal-actions > *{ flex:1 1 var(--pf-bgrid-basis); min-width:0; }
.pf-modal-actions .pf-btn{ width:100%; justify-content:center; }

/* Large remote-content modal (admin detail views) */
.pf-modal-card--lg{ width:min(94vw,620px); max-height:min(88vh,760px); overflow-y:auto; padding:0; }
/* The remote loaders hold a SKELETON now, not a centred "Loading…" line, so
   the placeholder has the reading direction and the padding of the content
   that replaces it — which is what stops the panel re-laying-itself out the
   moment the fetch lands. */
.pf-remote-loading{ padding:1.25rem 1.5rem; color:#94a3b8; font-size:.875rem; }
.pf-remote-error{ padding:2.5rem 1.5rem; text-align:center; color:#64748b; font-size:.875rem; }
.pf-remote-error a{ color:var(--pf-primary-ink); font-weight:600; }
.pf-spinner{ display:inline-block; width:1rem; height:1rem; margin-right:.4rem; vertical-align:-2px;
  border:2px solid var(--pf-primary-border); border-top-color:var(--pf-brand-violet); border-radius:50%;
  animation:pf-spin .7s linear infinite; }
@keyframes pf-spin{ to{ transform:rotate(360deg); } }

/* ---- Visibility toggle state (row carries data-public="0|1") ---- */
[data-public="1"] .pf-vis-off{ display:none; }
[data-public="0"] .pf-vis-on{ display:none; }

/* ---- Empty / error states ----
   Same anatomy for "nothing here yet", "no results", and "something broke":
   icon, one-line title, one-line explanation, one action. */
.pf-empty{ text-align:center; padding:3rem 1.5rem; }
.pf-empty-icon{ width:3.5rem; height:3.5rem; border-radius:14px; display:grid; place-items:center;
  margin:0 auto 1rem; background:var(--pf-surface-strong); color:var(--pf-muted); }
.pf-empty-title{ font-size:1rem; font-weight:700; color:var(--pf-text); }
.pf-empty-text{ font-size:.875rem; color:var(--pf-muted); margin-top:.35rem; max-width:38ch;
  margin-inline:auto; line-height:1.6; }
.pf-empty-actions{ display:flex; flex-wrap:wrap; gap:.5rem; justify-content:center; margin-top:1.25rem; }
.pf-empty-icon--danger{ background:#FEF2F2; color:var(--pf-danger); }
.pf-empty-icon--warning{ background:#FFFBEB; color:var(--pf-warning); }

/* ---- Skip link (WCAG 2.4.1 "Bypass Blocks") ----
   Every layout whose pages repeat a navigation block needs one keyboard
   bypass to the content. It is visually hidden until focused, then paints as
   a normal ink pill in the top-left. Kept as a component (not the inline
   utility chain the public layout used to carry) so the four layouts cannot
   drift apart, and it sits above the sticky headers and the nav rail. */
.pf-skip{ position:absolute; left:-9999px; top:0; width:1px; height:1px; overflow:hidden; }
.pf-skip:focus{ position:fixed; left:.75rem; top:.75rem; z-index:300;
  width:auto; height:auto; overflow:visible;
  padding:.55rem 1rem; border-radius:10px;
  background:var(--pf-ink); color:#fff; font-size:.875rem; font-weight:600;
  box-shadow:var(--pf-shadow-md); text-decoration:none; }

/* ====================== PUBLIC THEMES ====================== */

/* Theme: Developer Dark */
.theme-developer{ background:#0b1020; color:#cbd5e1; font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; }
.theme-developer .t-card{ background:#0f172a; border:1px solid #1e293b; border-radius:12px; }
.theme-developer a{ color:#22d3ee; }
.theme-developer .t-accent{ color:#22d3ee; }
.theme-developer .t-chip{ background:#0f172a; border:1px solid #1e293b; color:#7dd3fc; }
.theme-developer .t-mono-prompt::before{ content:"$ "; color:#22d3ee; }

/* Theme: Creative Designer */
.theme-creative{ background:#fffaf5; color:#1f2937; }
.theme-creative .t-hero{ background:linear-gradient(135deg,#fde68a 0%,#fca5a5 45%,#a78bfa 100%); }
.theme-creative .t-card{ background:#fff; border-radius:22px; box-shadow:0 12px 40px rgba(167,139,250,.18); }
.theme-creative .t-chip{ background:#f5f3ff; color:#6d28d9; border-radius:999px; }
.theme-creative h1,.theme-creative h2{ letter-spacing:-.02em; }

/* Theme: Academic */
.theme-academic{ background:#fbfaf7; color:#1f2933; font-family:Georgia,"Times New Roman",serif; }
.theme-academic .t-card{ background:#fff; border:1px solid #e7e2d6; border-radius:6px; }
.theme-academic .t-rule{ border-top:2px solid #1f2933; }
.theme-academic a{ color:#7c2d12; text-decoration:underline; }
.theme-academic .t-chip{ background:#f3efe6; color:#57534e; border-radius:4px; }

/* Theme: Consultant Premium */
.theme-consultant{ background:#0c1a2b; color:#e6edf5; }
.theme-consultant .t-hero{ background:linear-gradient(160deg,#0c1a2b,#13294a); }
.theme-consultant .t-card{ background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.1);
  border-radius:16px; backdrop-filter:blur(6px); }
.theme-consultant .t-gold{ color:#d4af37; }
.theme-consultant .t-chip{ background:rgba(212,175,55,.12); color:#e3c878; border:1px solid rgba(212,175,55,.3); }

/* Theme: Modern Personal Brand */
.theme-brand{ background:#0a0a0f; color:#e5e7eb; }
.theme-brand .t-hero{ background:radial-gradient(900px 500px at 20% -10%, rgba(236,72,153,.35), transparent 60%),
  radial-gradient(900px 500px at 90% 10%, rgba(99,102,241,.35), transparent 60%), #0a0a0f; }
.theme-brand .t-card{ background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.08); border-radius:18px; }
.theme-brand .t-chip{ background:rgba(255,255,255,.06); color:#f9a8d4; border-radius:999px; }
.theme-brand .t-grad{ background:linear-gradient(120deg,#f472b6,#818cf8); -webkit-background-clip:text; background-clip:text; color:transparent; }

/* Theme: Minimal (default) */
.theme-minimal{ background:#fff; color:#111827; }
.theme-minimal .t-card{ background:#fff; border:1px solid #eef0f3; border-radius:14px; }
.theme-minimal .t-chip{ background:#f3f4f6; color:#374151; border-radius:8px; }

/* Theme: Executive Minimal (premium) — centered editorial */
.theme-executive{ background:#fff; color:#0f172a; }
.theme-executive .ex-top{ background:rgba(255,255,255,.82); backdrop-filter:blur(10px); border-bottom:1px solid var(--pf-border); }
.theme-executive .ex-top a{ color:#475569; } .theme-executive .ex-top a:hover{ color:var(--pf-primary); }
.theme-executive .ex-avatar{ box-shadow:0 0 0 1px var(--pf-border), 0 10px 30px rgba(15,23,42,.08); }
.theme-executive .ex-name{ font-size:clamp(2rem,6vw,2.9rem); font-weight:800; letter-spacing:-.03em; line-height:1.05; }
.theme-executive .ex-headline{ color:var(--pf-primary); font-weight:600; font-size:1.1rem; }
.theme-executive .ex-rule{ height:1px; background:var(--pf-border); }
.theme-executive .t-card{ background:#fff; border:1px solid var(--pf-border); border-radius:12px; }
.theme-executive .t-chip{ background:#f1f5f9; color:#475569; border-radius:6px; }
.theme-executive .t-accent{ color:var(--pf-primary); }
.theme-executive a{ color:var(--pf-primary); }
.theme-executive section > h2{ font-size:.8rem; text-transform:uppercase; letter-spacing:.14em;
  color:var(--pf-muted); font-weight:700; border-bottom:1px solid var(--pf-border); padding-bottom:.7rem; }

/* Theme: Developer Pro Dark (premium) — fixed sidebar + mono accents */
.theme-devpro{ background:#0a0f1a; color:#cbd5e1; }
.theme-devpro .dp-card{ background:#0f172a; border:1px solid #1e293b; border-radius:16px; }
.theme-devpro .dp-name{ font-size:1.5rem; font-weight:800; color:#f8fafc; letter-spacing:-.01em; }
.theme-devpro .t-accent{ color:var(--pf-accent); }
.theme-devpro a{ color:var(--pf-accent); }
.theme-devpro .t-card{ background:#0f172a; border:1px solid #1e293b; border-radius:12px; }
.theme-devpro .t-chip{ background:#1e293b; color:#93c5fd; border-radius:6px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.72rem; }
.theme-devpro .dp-btn{ display:inline-flex; align-items:center; gap:.5rem; font-weight:600; font-size:.9rem;
  border-radius:10px; padding:.625rem 1.1rem; background:#1e293b; color:#e2e8f0; border:1px solid #334155; transition:var(--pf-dur-1); }
.theme-devpro .dp-btn:hover{ background:#334155; }
.theme-devpro .pf-input{ background:#0b1220; border-color:#1e293b; color:#e2e8f0; }
.theme-devpro .pf-input::placeholder{ color:#64748b; }
.theme-devpro .pf-skillbar{ background:#1e293b; }
.theme-devpro section > h2{ font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:1.05rem; color:#f8fafc; font-weight:700; }
.theme-devpro section > h2::before{ content:"// "; color:var(--pf-accent); }

/* Theme: Creative Studio (premium) — bold asymmetric */
.theme-studio{ background:#fafafa; color:#18181b; }
.theme-studio .st-hero{ background:linear-gradient(135deg,rgba(37,99,235,.10),rgba(139,92,246,.12) 60%,rgba(20,184,166,.10)); border-bottom:1px solid var(--pf-border); }
.theme-studio .st-avatar{ box-shadow:0 12px 40px rgba(139,92,246,.25); }
.theme-studio .st-eyebrow{ text-transform:uppercase; letter-spacing:.18em; font-size:.72rem; font-weight:700; color:var(--pf-accent); }
.theme-studio .st-name{ font-size:clamp(2.4rem,8vw,4rem); font-weight:900; letter-spacing:-.04em; line-height:.98;
  background:linear-gradient(120deg,var(--pf-primary),var(--pf-accent)); -webkit-background-clip:text; background-clip:text; color:transparent; }
.theme-studio .st-tag{ font-size:1.05rem; opacity:.8; }
.theme-studio .t-card{ background:#fff; border:1px solid var(--pf-border); border-radius:18px; }
.theme-studio .t-chip{ background:#f4f4f5; color:#3f3f46; border-radius:999px; }
.theme-studio .t-accent{ color:var(--pf-accent); }
.theme-studio a{ color:var(--pf-primary); }
.theme-studio section > h2{ font-size:1.7rem; font-weight:800; letter-spacing:-.02em; }

/* Theme: Academic Scholar (premium) — serif document */
.theme-scholar{ background:#fcfcfa; color:#1f2933; font-family:Georgia,'Times New Roman',serif; }
.theme-scholar .sc-name{ font-size:2.3rem; font-weight:700; letter-spacing:-.01em; }
.theme-scholar .sc-title{ font-style:italic; color:var(--pf-muted); font-size:1.1rem; }
.theme-scholar .sc-meta a, .theme-scholar a{ color:#7c2d12; text-decoration:underline; }
.theme-scholar .sc-rule{ border-top:2px solid #1f2933; }
.theme-scholar .t-card{ background:#fff; border:1px solid #e7e2d6; border-radius:4px; }
.theme-scholar .t-chip{ background:#f3efe6; color:#57534e; border-radius:3px; }
.theme-scholar .t-accent{ color:#7c2d12; }
.theme-scholar section > h2{ font-family:Georgia,serif; font-size:1.4rem; font-weight:700; border-bottom:1px solid #d6d3c8; padding-bottom:.4rem; }

/* Theme: Freelancer Showcase (premium) — services-forward */
/* This theme is designed teal throughout (hero wash, chip, badge) and borrows
   --pf-secondary for its headline, accent and links. That token is now the
   logo's sky, so the teal is pinned here instead — otherwise moving a platform
   token would have repainted half of every published freelancer profile and
   left the other half teal. */
.theme-freelancer{ background:#f8fafc; color:#0f172a; --pf-secondary:#14B8A6; }
.theme-freelancer .fr-hero{ background:radial-gradient(900px 360px at 50% -20%, rgba(20,184,166,.16), transparent 70%); border-bottom:1px solid var(--pf-border); }
.theme-freelancer .fr-avatar{ box-shadow:0 0 0 4px #fff, 0 10px 30px rgba(15,23,42,.10); }
.theme-freelancer .fr-badge{ display:inline-flex; align-items:center; gap:.45rem; background:#ecfdf5; color:#047857;
  border:1px solid #a7f3d0; border-radius:999px; padding:.3rem .8rem; font-size:.8rem; font-weight:600; }
.theme-freelancer .fr-dot{ width:.5rem; height:.5rem; border-radius:999px; background:#10b981; box-shadow:0 0 0 3px rgba(16,185,129,.25); }
.theme-freelancer .fr-name{ font-size:2.4rem; font-weight:800; letter-spacing:-.02em; }
.theme-freelancer .fr-headline{ color:var(--pf-secondary); font-weight:600; font-size:1.1rem; }
.theme-freelancer .t-card{ background:#fff; border:1px solid var(--pf-border); border-radius:16px; }
.theme-freelancer .t-chip{ background:#f0fdfa; color:#0f766e; border-radius:999px; }
.theme-freelancer .t-accent{ color:var(--pf-secondary); }
.theme-freelancer a{ color:var(--pf-secondary); }
.theme-freelancer section > h2{ font-size:1.6rem; font-weight:800; }

/* Theme: Personal Brand Premium (premium) — dark glassy mesh */
.theme-brandpro{ color:#e5e7eb; background:
  radial-gradient(900px 500px at 15% -10%, rgba(139,92,246,.30), transparent 60%),
  radial-gradient(900px 500px at 90% 0%, rgba(37,99,235,.28), transparent 58%), #0a0a12; }
.theme-brandpro .bp-avatar{ box-shadow:0 0 0 2px rgba(255,255,255,.15), 0 16px 50px rgba(139,92,246,.4); }
.theme-brandpro .bp-name{ font-size:clamp(2.4rem,7vw,3.6rem); font-weight:900; letter-spacing:-.03em; }
.theme-brandpro .bp-grad{ background:linear-gradient(120deg,#a78bfa,#60a5fa,#5eead4); -webkit-background-clip:text; background-clip:text; color:transparent; }
.theme-brandpro .bp-headline{ color:#c4b5fd; font-weight:600; font-size:1.15rem; }
.theme-brandpro .bp-stat{ font-size:1.9rem; font-weight:800; color:#fff; }
.theme-brandpro .t-card{ background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.10); border-radius:18px; backdrop-filter:blur(8px); }
.theme-brandpro .t-chip{ background:rgba(255,255,255,.06); color:#ddd6fe; border-radius:999px; }
.theme-brandpro .t-accent{ color:#a78bfa; } .theme-brandpro a{ color:#c4b5fd; }
.theme-brandpro .pf-input{ background:rgba(255,255,255,.05); border-color:rgba(255,255,255,.12); color:#e5e7eb; }
.theme-brandpro .pf-input::placeholder{ color:#9ca3af; }
.theme-brandpro .pf-skillbar{ background:rgba(255,255,255,.10); }
.theme-brandpro section > h2{ font-size:1.7rem; font-weight:800; color:#f8fafc; }

/* Theme: Modern Resume (premium) — two-column document */
.theme-resume{ background:#fff; color:#111827; }
.theme-resume .rs-head{ border-bottom:2px solid var(--pf-text); }
.theme-resume .rs-name{ font-size:2rem; font-weight:800; letter-spacing:-.02em; }
.theme-resume .rs-title{ color:var(--pf-primary); font-weight:600; }
.theme-resume .rs-body aside{ border-right:1px solid var(--pf-border); padding-right:2.5rem; }
@media (max-width:767px){ .theme-resume .rs-body aside{ border-right:0; padding-right:0; } }
.theme-resume .t-card{ background:#fff; border:1px solid var(--pf-border); border-radius:8px; }
.theme-resume .t-chip{ background:#eff6ff; color:#1d4ed8; border-radius:4px; }
.theme-resume .t-accent{ color:var(--pf-primary); } .theme-resume a{ color:var(--pf-primary); }
.theme-resume section > h2{ font-size:.85rem; text-transform:uppercase; letter-spacing:.1em; color:var(--pf-primary); font-weight:700; }

/* Theme: Portfolio Gallery (premium) — visual-first */
.theme-gallery{ background:#0c0c0d; color:#e5e7eb; }
.theme-gallery .gl-bar{ background:rgba(12,12,13,.85); backdrop-filter:blur(10px); border-bottom:1px solid #232325; }
.theme-gallery .gl-intro{ border-bottom:1px solid #1c1c1e; }
.theme-gallery .gl-tag{ font-size:clamp(1.5rem,4vw,2.4rem); font-weight:700; letter-spacing:-.02em; line-height:1.2; }
.theme-gallery #projects .t-card{ border-radius:14px; }
.theme-gallery #projects img{ height:18rem; }
.theme-gallery .t-card{ background:#161617; border:1px solid #232325; border-radius:12px; }
.theme-gallery .t-chip{ background:#232325; color:#d4d4d8; border-radius:6px; }
.theme-gallery .t-accent{ color:var(--pf-accent); } .theme-gallery a{ color:#e5e7eb; }
.theme-gallery .pf-input{ background:#161617; border-color:#232325; color:#e5e7eb; }
.theme-gallery .pf-skillbar{ background:#232325; }
.theme-gallery section > h2{ font-size:1.6rem; font-weight:800; color:#fafafa; }
/* No verification-badge rule here any more. This theme used to need one — its
   name is not a heading but a 16px sticky bar, where the old `.62em` badge
   landed at ~10px — and that hand-tuned 1.05em was the only per-theme badge
   size in the product. The shared component's clamp now holds the floor for
   every template at once (.pf-vtick, further down), which puts gallery at 16px
   without a special case. Do not reintroduce a per-theme badge size. */

/* Theme: Consultant Elite (premium) — light luxury, serif + gold */
.theme-elite{ background:#fbfaf7; color:#1c1917; }
.theme-elite .el-hero{ background:linear-gradient(180deg,#fffdf8,#fbfaf7); border-bottom:1px solid #ece7db; }
.theme-elite .el-avatar{ box-shadow:0 0 0 1px #e7dcc3, 0 10px 30px rgba(28,25,23,.10); }
.theme-elite .el-name{ font-family:Georgia,serif; font-size:2.4rem; font-weight:700; letter-spacing:-.01em; }
.theme-elite .el-title{ color:#b08d3a; font-weight:600; letter-spacing:.02em; text-transform:uppercase; font-size:.85rem; }
.theme-elite .el-divider{ width:3rem; height:2px; background:#b08d3a; }
.theme-elite .t-card{ background:#fff; border:1px solid #ece7db; border-radius:10px; }
.theme-elite .t-chip{ background:#f5efe2; color:#8a6d2f; border-radius:4px; }
.theme-elite .t-accent{ color:#b08d3a; } .theme-elite a{ color:#8a6d2f; }
.theme-elite section > h2{ font-family:Georgia,serif; font-size:1.5rem; font-weight:700; }

/* Theme: Student Starter (premium) — friendly, education-first */
.theme-student{ background:#f5f7ff; color:#1e293b; }
.theme-student .sd-hero{ background:radial-gradient(800px 320px at 50% -30%, rgba(37,99,235,.14), transparent 70%); }
.theme-student .sd-card{ background:#fff; border:1px solid var(--pf-border); border-radius:24px; box-shadow:0 10px 40px rgba(37,99,235,.08); }
.theme-student .sd-name{ font-size:2.1rem; font-weight:800; letter-spacing:-.02em; }
.theme-student .sd-headline{ color:var(--pf-primary); font-weight:600; }
.theme-student .t-card{ background:#fff; border:1px solid var(--pf-border); border-radius:18px; }
.theme-student .t-chip{ background:#eef2ff; color:#4338ca; border-radius:999px; }
.theme-student .t-accent{ color:var(--pf-primary); } .theme-student a{ color:var(--pf-primary); }
.theme-student section > h2{ font-size:1.5rem; font-weight:800; }

/* Theme: Tech Founder (premium) — startup landing */
.theme-founder{ background:#ffffff; color:#0f172a; }
.theme-founder .fd-hero{ background:linear-gradient(180deg,#f8fafc,#ffffff); border-bottom:1px solid var(--pf-border); }
.theme-founder .fd-avatar{ box-shadow:0 8px 24px rgba(15,23,42,.12); }
.theme-founder .fd-name{ font-size:clamp(2.4rem,7vw,3.8rem); font-weight:900; letter-spacing:-.035em; line-height:1; }
.theme-founder .fd-headline{ color:var(--pf-primary); font-weight:700; font-size:1.15rem; }
.theme-founder .fd-mission{ font-size:1.25rem; line-height:1.5; opacity:.8; }
.theme-founder .fd-stat{ font-size:2.2rem; font-weight:900; color:var(--pf-primary); }
.theme-founder .t-card{ background:#fff; border:1px solid var(--pf-border); border-radius:16px; box-shadow:0 1px 2px rgba(16,24,40,.04); }
.theme-founder .t-chip{ background:#f1f5f9; color:#475569; border-radius:6px; }
.theme-founder .t-accent{ color:var(--pf-primary); } .theme-founder a{ color:var(--pf-primary); }
.theme-founder section > h2{ font-size:1.8rem; font-weight:800; letter-spacing:-.02em; }

/* Theme: Elegant Classic (premium) — timeless serif */
.theme-classic{ background:#fffefb; color:#1a1a1a; font-family:Georgia,'Times New Roman',serif; }
.theme-classic .cl-avatar{ box-shadow:0 0 0 1px #e5e0d5; }
.theme-classic .cl-rule-top{ width:4rem; height:1px; background:#b8a98a; }
.theme-classic .cl-rule-bottom{ width:6rem; height:1px; background:#b8a98a; }
.theme-classic .cl-name{ font-size:2.4rem; font-weight:700; letter-spacing:.01em; }
.theme-classic .cl-title{ font-style:italic; color:#6b6357; font-size:1.1rem; }
.theme-classic .t-card{ background:#fff; border:1px solid #ece7da; border-radius:2px; }
.theme-classic .t-chip{ background:#f4efe4; color:#6b6357; border-radius:2px; }
.theme-classic .t-accent{ color:#9a7b3f; } .theme-classic a{ color:#9a7b3f; }
.theme-classic section > h2{ font-family:Georgia,serif; font-size:1.4rem; font-weight:700; text-align:center;
  letter-spacing:.04em; }

/* Theme link-colour rules (.theme-x a{color:var(--pf-primary)}) must never
   repaint solid action-button labels — primary-on-primary text is invisible. */
[class^="theme-"] .pf-btn-primary,[class*=" theme-"] .pf-btn-primary,
[class^="theme-"] a.pf-btn-primary,[class*=" theme-"] a.pf-btn-primary{ color:#fff; }
/* Gold/amber/cyan primaries need dark labels to pass contrast. */
.theme-consultant .pf-btn-primary,.theme-gallery .pf-btn-primary,
.theme-elite .pf-btn-primary,.theme-classic .pf-btn-primary,
.theme-noir .pf-btn-primary{ color:#171310; }
.theme-developer .pf-btn-primary{ color:#0b1020; }

/* Testimonial rating stars: filled up to the rating, dimmed outline after. */
.pf-star-fill{ fill:currentColor; }
.pf-star-dim{ opacity:.3; }

/* Themes whose accent is a dark neutral get muddy primary→accent gradients;
   solid primary reads cleaner (still follows saved custom primaries). */
.theme-elite .pf-skillbar > span,.theme-editorial .pf-skillbar > span,
.theme-classic .pf-skillbar > span,.theme-scholar .pf-skillbar > span,
.theme-gallery .pf-skillbar > span{ background:var(--pf-primary); }

/* Quiet translucent ghost buttons on the dark heroes (default is white). */
.theme-developer .pf-btn-ghost,.theme-consultant .pf-btn-ghost,
.theme-brand .pf-btn-ghost,.theme-gallery .pf-btn-ghost,
.theme-brandpro .pf-btn-ghost{
  background:rgba(255,255,255,.07); color:#e5e7eb; border-color:rgba(255,255,255,.18); }
.theme-developer .pf-btn-ghost:hover,.theme-consultant .pf-btn-ghost:hover,
.theme-brand .pf-btn-ghost:hover,.theme-gallery .pf-btn-ghost:hover,
.theme-brandpro .pf-btn-ghost:hover{
  background:rgba(255,255,255,.14); border-color:rgba(255,255,255,.3); }

/* Lazy image fade-in */
img[loading="lazy"]{ background:#f3f4f6; }

/* Dashboard/admin header — ONE shared component (partials/dash_header.php) for
   both the user dashboard and the admin panel. 3-column layout
   (left breadcrumb/title | centre search | right controls). Identical on
   desktop and mobile; only the profile trigger compacts to avatar-only < sm. */
.pf-hdr{ height:4rem; flex:0 0 auto; display:flex; align-items:center; gap:.5rem;
  padding:0 .75rem; position:sticky; top:0; z-index:30;
  background:rgba(255,255,255,.9); -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);
  border-bottom:1px solid var(--pf-border); }
.pf-hdr-left{ flex:1; min-width:0; display:flex; align-items:center; gap:.5rem; }
/* The search box gives up width before anything else. It was `flex-shrink:0`
   at a fixed 18rem, which the header cannot afford once the nav shell takes
   its 320px: at a 768px viewport the content column is 448px, and 18rem of
   unshrinkable search plus the right-hand controls pushed that cluster ~15px
   past the viewport edge, clipping the account button. A search field is the
   one control here that stays usable at any width. */
.pf-hdr-center{ position:relative; width:18rem; min-width:0; flex:0 1 18rem; }
.pf-hdr-center input{ padding-left:2.25rem; }
/* `flex:0 0 auto`, NOT `flex:1`. This cluster is a fixed set of controls — a
   bell and an avatar — but claiming an equal share meant it reserved 179px on
   a 390px screen for ~90px of content, and the breadcrumb opposite was
   squeezed into 131px and truncated to "Laun… / W…". Sized to its content, the
   left side gets that space back. `min-width:0` still lets the account name
   ellipsise on the wide layouts where it is shown. */
.pf-hdr-right{ flex:0 0 auto; min-width:0; display:flex; align-items:center; justify-content:flex-end; gap:.5rem; }
@media (min-width:640px){ .pf-hdr{ padding:0 1.25rem; gap:.75rem; } .pf-hdr-right{ gap:.5rem; } }

/* Right-cluster controls — Notifications + Profile are one component family:
   same 2.5rem control height, same hover/focus, sitting on a shared gap. */
.pf-hdr-iconbtn{ position:relative; display:inline-grid; place-items:center; width:2.5rem; height:2.5rem;
  border-radius:12px; color:var(--pf-muted); background:transparent; cursor:pointer;
  transition:background-color var(--pf-dur-2) var(--pf-ease), color var(--pf-dur-2) var(--pf-ease); }
.pf-hdr-iconbtn:hover{ background:var(--pf-surface-soft); color:var(--pf-text); }
.pf-hdr-iconbtn:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }
.pf-hdr-badge{ position:absolute; top:.15rem; right:.15rem; min-width:1.1rem; height:1.1rem;
  padding:0 .28rem; border-radius:999px; background:var(--pf-danger); color:#fff;
  font-size:10px; font-weight:700; line-height:1; display:grid; place-items:center;
  box-shadow:0 0 0 2px var(--pf-surface); }
.pf-hdr-profile{ display:inline-flex; align-items:center; gap:.5rem; height:2.5rem;
  padding:.2rem .5rem .2rem .2rem; border-radius:999px; color:var(--pf-text);
  background:transparent; cursor:pointer; max-width:12rem;
  transition:background-color var(--pf-dur-2) var(--pf-ease), box-shadow var(--pf-dur-2) var(--pf-ease); }
.pf-hdr-profile:hover{ background:var(--pf-surface-soft); }
.pf-hdr-profile:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }
.pf-hdr-profile-name{ min-width:0; max-width:8rem; overflow:hidden; text-overflow:ellipsis;
  white-space:nowrap; font-size:.875rem; font-weight:600; letter-spacing:-.01em; }
.pf-hdr-profile-caret{ display:grid; place-items:center; color:var(--pf-muted-2); }
/* ====================== DATE PICKER (.pf-cal) =========================
   The product's calendar popover — see the long comment above the controller
   in assets/js/app.js for why the native control was replaced.

   ONE MONTH AT EVERY WIDTH. There is no second pane and no breakpoint that
   adds one: the popover is a fixed-width card (19.5rem, capped to the
   viewport) holding a single six-row grid. Everything below is sized off that
   one assumption — the 7-column track, the ~40px cell, the tap targets.

   Colour contract: the ONLY saturated colour is the selection, which uses the
   product primary. Today is an outline, never a fill, so it can sit next to a
   selected day without the two competing for the same meaning. Everything else
   is ink and neutrals, so a dense grid still reads as calm.

   Structure: every day is a square button whose visible chip (.pf-cal-n) is
   inset inside it. Range shading paints the FULL cell behind that chip, so a
   span joins edge-to-edge across the row while the days themselves stay round.

   The day grid is seven per row but is NOT a calendar: it lists the days the
   month actually has, in reading order, with no weekday header row and no
   days borrowed from the neighbouring months. Seven is the track width, not a
   week — see the .pf-cal-grid block below.

   WHAT IS DELIBERATELY NOT HERE. No card inside the card, no separators the
   spacing can express on its own, no glow under the selected day, no second
   accent. The grid of numbers is the interface; everything else is either a
   control that changes it or it was deleted. When adding to this component,
   the question is not "does this look good" but "what can the user not do
   without it".
*/
.pf-dp-field{ position:relative; display:block; }
.pf-dp-field .pf-input{ padding-right:2.25rem; }
.pf-dp-icon{ position:absolute; right:.7rem; top:50%; transform:translateY(-50%);
  color:var(--pf-muted-2); pointer-events:none; display:grid; place-items:center;
  transition:color var(--pf-dur-1) var(--pf-ease); }
.pf-dp-field:focus-within .pf-dp-icon{ color:var(--pf-text); }

/* CENTRED IN THE VIEWPORT, ALWAYS WHOLE.
   This used to be anchored under its field and clamped with JS: measure the
   box, prefer below, flip above, cap the height when neither side fitted. On a
   field near the bottom of a long form that produced the two bugs this
   component was most often reported for — a card whose footer sat below the
   fold (so Done could not be reached without scrolling the page out from under
   the calendar it was anchored to), and, because the card was positioned in
   DOCUMENT coordinates, one that could extend the document itself and grow a
   scrollbar just by opening.

   `position:fixed` with `inset:0; margin:auto` removes both at the root: the
   card is centred in the VIEWPORT, so it cannot be off-screen, and it is out
   of the document's flow entirely, so it cannot lengthen the page. Nothing
   measures anything.

   `height:fit-content` is what makes the auto margins centre rather than
   stretch, and the max-height is the whole promise of the layout below: the
   card never exceeds the screen, the head and foot never shrink, and the body
   takes the overflow. */
.pf-cal{ position:fixed; inset:0; margin:auto; z-index:120;
  width:min(19.5rem, calc(100vw - 1.5rem));
  height:fit-content;
  max-height:calc(100vh - 1.5rem);
  max-height:calc(100dvh - 1.5rem);
  display:flex; flex-direction:column;
  padding:.75rem .7rem .7rem;
  background:var(--pf-surface);
  border:1px solid var(--pf-border);
  border-radius:16px;
  /* One shadow. Its only job is to say "this floats above the page" — a second
     and third layer were saying it again, more expensively. */
  box-shadow:0 16px 40px -16px rgba(17,19,24,.28);
  color:var(--pf-text);
  /* hidden, not auto: the CARD must never scroll, or the footer scrolls with
     it. Only .pf-cal-body does. */
  overflow:hidden; overscroll-behavior:contain;
  -webkit-tap-highlight-color:transparent; }
.pf-cal[hidden]{ display:none; }

/* Background scroll lock while a picker is open (class set by datepicker.js
   through the shared window.pfScrollLock).

   The real lock is <body> being taken out of flow by that module — this rule
   only has to hold the line on browsers where `overflow:hidden` IS enough, and
   to stop the frozen body from showing a second scrollbar.

   DELIBERATELY NOT WRAPPED IN A MEDIA QUERY. The lock is touch-only, but that
   gate lives in ONE place — `COARSE.matches` in datepicker.js, which is also
   what decides whether the class is ever added. A `(pointer:coarse)` here
   would be a second gate saying the same thing, and the failure mode of two
   gates is that they drift apart and the class gets set with no rule behind
   it. The class is never present on a fine pointer, so the query would not
   change a single frame. */
html.pf-cal-open, html.pf-cal-open body{ overflow:hidden; overscroll-behavior:none; }
/* The card's only in-flow child (the live region beside it is .sr-only and out
   of flow), and therefore the real column: head, body, optional time row,
   optional note, foot. `min-height:0` here as well as on the body — a chain of
   flex items each refuses to shrink below its content until every link in it
   is told otherwise. */
.pf-cal-inner{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; }

/* The dim behind the card. Shown only on touch (datepicker.js gates it on the
   same COARSE query as the scroll lock) — a desktop popover is small, anchored
   in a window the reader can see all of, and does not earn a modal's weight.

   z-index 119, one under the card: the two are siblings on <body>, so this is
   the whole of the stacking relationship between them.

   rgba(15,23,42,.45) is the product's existing overlay value — the same dim
   the mobile drawer uses (.pf-overlay). Dark enough to say the page behind is
   out of play, light enough to keep the form readable underneath, which is
   what stops the picker feeling like a page you navigated to. */
.pf-cal-scrim{ position:fixed; inset:0; z-index:119;
  background:rgba(15,23,42,.45);
  -webkit-tap-highlight-color:transparent; }
.pf-cal-scrim[hidden]{ display:none; }

/* Opening motion only — no exit animation, because a picker that lingers on
   close feels slower than one that simply goes. The dim follows the same rule
   for a harder reason: Done and Cancel must leave NOTHING on screen still
   running when the scroll position is restored, or the restore happens under
   a fading overlay and reads as the page moving. */
.pf-cal-anim{ animation:pf-cal-in var(--pf-dur-2) var(--pf-ease); }
@keyframes pf-cal-in{ from{ opacity:0; transform:translateY(-6px) scale(.98); } }
/* A separate class, not .pf-cal-anim: that one is a bare class selector and
   would give the scrim the card's translate-and-scale entrance. */
.pf-cal-scrim.is-in{ animation:pf-cal-scrim-in var(--pf-dur-2) var(--pf-ease); }
@keyframes pf-cal-scrim-in{ from{ opacity:0; } }
/* Month steps carry a 5px drift in the direction of travel — just enough to
   say "the same grid moved", not enough to make the user wait for it. */
.pf-cal-body.is-next{ animation:pf-cal-next var(--pf-dur-2) var(--pf-ease); }
.pf-cal-body.is-prev{ animation:pf-cal-prev var(--pf-dur-2) var(--pf-ease); }
.pf-cal-body.is-zoom{ animation:pf-cal-zoom var(--pf-dur-2) var(--pf-ease); }
@keyframes pf-cal-next{ from{ opacity:0; transform:translateX(5px); } }
@keyframes pf-cal-prev{ from{ opacity:0; transform:translateX(-5px); } }
@keyframes pf-cal-zoom{ from{ opacity:0; transform:scale(.97); } }
@media (prefers-reduced-motion:reduce){
  .pf-cal-anim,.pf-cal-body.is-next,.pf-cal-body.is-prev,.pf-cal-body.is-zoom,
  .pf-cal-scrim.is-in{ animation:none; }
}

/* ---- header: [‹] month year ⌄ [›] ---- */
.pf-cal-head{ flex:0 0 auto; display:flex; align-items:center; gap:.2rem; padding:0 .1rem .5rem; }
.pf-cal-nav{ flex:0 0 auto; display:grid; place-items:center; width:2rem; height:2rem;
  border:0; border-radius:10px; background:transparent; color:var(--pf-muted);
  cursor:pointer; padding:0;
  transition:background-color var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease); }
.pf-cal-nav svg{ width:1.05rem; height:1.05rem; }
.pf-cal-nav:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }
.pf-cal-nav:active{ transform:scale(.94); }
.pf-cal-nav:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }
.pf-cal-nav:disabled{ opacity:.3; cursor:not-allowed; background:transparent; }

/* Month and year are two separate controls. A single "July 2026" chip hid the
   year behind a guess about what the caret would open; two labelled chips say
   what each one does and put both one click away. */
.pf-cal-titles{ flex:1 1 auto; min-width:0; display:flex; align-items:center;
  justify-content:center; gap:.1rem; }
.pf-cal-title{ display:inline-flex; align-items:center; gap:.25rem; height:2rem;
  padding:0 .45rem; border:0; border-radius:9px; background:transparent; cursor:pointer;
  font:inherit; font-size:.9375rem; font-weight:650; letter-spacing:-.015em;
  font-variant-numeric:tabular-nums; color:var(--pf-text); white-space:nowrap;
  transition:background-color var(--pf-dur-1) var(--pf-ease),
             color var(--pf-dur-1) var(--pf-ease); }
/* The year sits back a step: on a calendar you are nearly always changing the
   month, and the year is context until you reach for it. */
.pf-cal-title-year{ color:var(--pf-muted); font-weight:550; }
.pf-cal-title svg{ width:.7rem; height:.7rem; color:var(--pf-muted-2); flex:0 0 auto;
  transition:transform var(--pf-dur-1) var(--pf-ease); }
.pf-cal-title:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }
.pf-cal-title:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }
.pf-cal-title[aria-expanded="true"]{ background:var(--pf-surface-strong); color:var(--pf-text); }
.pf-cal-title[aria-expanded="true"] svg{ transform:rotate(180deg); color:var(--pf-text); }
/* Kept in the flow, not removed: the arrows have nothing to step while the
   year list is open, and dropping them would slide the title sideways. */
.pf-cal-nav.is-blank{ visibility:hidden; pointer-events:none; }

/* The ONLY part of the card that scrolls. `min-height:0` is what lets it
   actually shrink inside the flex column — without it a flex item refuses to
   go below its content's height, the card grows past its max-height, and the
   footer is pushed off the bottom of the screen: precisely the failure this
   layout exists to prevent. */
.pf-cal-body{ flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain;
  /* Scrolling one axis makes the other a clipping edge, and that edge would
     otherwise fall exactly on the focus ring of the first and last column.
     The pair cancels out — the content box is unchanged — and buys the ring
     the few pixels it needs. */
  padding-inline:.15rem; margin-inline:-.15rem;
  display:grid; align-content:start; }

/* ---- day grid ----
   Seven per row, and that is now ALL the seven means. There is no weekday
   header row and no borrowed days from the neighbouring months: the grid holds
   the days the month actually has, 1..28/29/30/31, in reading order. Seven
   columns are kept because everything below is already built on that track —
   the cell size, the chip, the range ribbon — and because five rows of seven
   is the shape that fits the card. */
/* The same 16.25rem box the month and year lists occupy, so switching views
   never resizes the card — and the same reason it matters more than it used
   to: the card is centred in the viewport, so a height change moves BOTH
   edges rather than just the bottom one.

   Five rows of squares come to about 12.9rem at this width, so there is always
   room to spare inside that box. `align-content:start` spends it all at the
   BOTTOM: stepping from March into February then drops the fifth row off the
   end, instead of re-centring and sliding the four rows that did not change. */
.pf-cal-grid{ display:grid; align-content:start; min-height:16.25rem; }
/* No column gap: the cells must touch or a range would show seams between
   consecutive days. The breathing room lives inside each cell instead. */
.pf-cal-row{ display:grid; grid-template-columns:repeat(7,1fr); }

.pf-cal-day{ position:relative; aspect-ratio:1; display:grid; place-items:center;
  border:0; background:transparent; padding:0; margin:0; cursor:pointer; font:inherit;
  font-size:.8125rem; font-weight:500; font-variant-numeric:tabular-nums;
  color:var(--pf-text); -webkit-tap-highlight-color:transparent; }
/* The chip is the thing that reads as "a day" — hover, today, selection and
   focus all land here, never on the square cell, so the grid stays round. */
.pf-cal-n{ position:relative; z-index:2; display:grid; place-items:center;
  width:2.125rem; height:2.125rem; border-radius:999px; line-height:1;
  transition:background-color var(--pf-dur-1) var(--pf-ease),
             color var(--pf-dur-1) var(--pf-ease),
             box-shadow var(--pf-dur-1) var(--pf-ease),
             transform var(--pf-dur-1) var(--pf-ease); }
.pf-cal-day:hover:not(.is-selected):not([aria-disabled="true"]) .pf-cal-n{
  background:var(--pf-surface-strong); }
.pf-cal-day:active:not([aria-disabled="true"]) .pf-cal-n{ transform:scale(.92); }
/* The ring hugs the chip and is drawn OUTSIDE it against the card, so a focus
   ring never collides with the neighbouring day. */
.pf-cal-day:focus-visible{ outline:none; }
.pf-cal-day:focus-visible .pf-cal-n{
  box-shadow:0 0 0 2px var(--pf-surface), 0 0 0 4px var(--pf-ink); z-index:3; }

/* Disabled (min/max/data-dp-disabled) stays VISIBLE but recedes — a hole in
   the grid is harder to scan than a dimmed number.

   The `.is-out` state that used to live here is gone with the days it
   described: no cell in the grid belongs to another month any more, so there
   is nothing left to dim for that reason. */
.pf-cal-day[aria-disabled="true"]{ color:var(--pf-muted-2); opacity:.4; cursor:not-allowed; }

/* Today: a ring, never a fill. */
.pf-cal-day.is-today .pf-cal-n{ box-shadow:inset 0 0 0 1.5px var(--pf-border-strong); font-weight:600; }

/* Selection: a filled chip and nothing else. It is already the only saturated
   thing on the card — a glow under it would be volume on a shout. */
.pf-cal-day.is-selected .pf-cal-n{
  background:var(--pf-primary); color:#fff; font-weight:600; }

/* Range. The band is a full-bleed pseudo-element BEHIND the chips, with the
   ends of each row rounded, so a span reads as one continuous ribbon that
   wraps at the week boundary. */
.pf-cal-day.in-range::before,
.pf-cal-day.is-range-start::before,
.pf-cal-day.is-range-end::before{
  content:""; position:absolute; top:.1875rem; bottom:.1875rem; z-index:1;
  background:rgb(var(--pf-primary-rgb) / 10%); }
/* The half-pixel bleed is deliberate: sub-pixel column rounding leaves hairline
   seams between touching cells at some zoom levels, and a ribbon with gaps in
   it looks broken rather than subtle. */
.pf-cal-day.in-range::before{ left:-.5px; right:-.5px; }
.pf-cal-day.in-range.is-preview::before{ background:rgb(var(--pf-primary-rgb) / 6%); }
.pf-cal-day.is-range-start::before{ left:50%; right:-.5px; }
.pf-cal-day.is-range-end::before{ left:-.5px; right:50%; }
.pf-cal-day.is-solo::before{ display:none; }
/* The end under the pointer: outlined in the accent, so "where this would end"
   never looks like "where this ends". */
.pf-cal-day.is-ghost .pf-cal-n{ color:var(--pf-primary-ink); font-weight:700;
  box-shadow:inset 0 0 0 1.5px rgb(var(--pf-primary-rgb) / 55%); }
/* Rounded only where the ribbon meets the edge of a week, so a span that wraps
   reads as one shape continued rather than two unrelated bars. */
.pf-cal-row .pf-cal-day.in-range:first-child::before,
.pf-cal-row .pf-cal-day.is-range-end:first-child::before{
  border-top-left-radius:999px; border-bottom-left-radius:999px; }
.pf-cal-row .pf-cal-day.in-range:last-child::before,
.pf-cal-row .pf-cal-day.is-range-start:last-child::before{
  border-top-right-radius:999px; border-bottom-right-radius:999px; }

/* Event indicator — one small dot, never a second colour system. */
.pf-cal-day.has-event .pf-cal-n::after{ content:""; position:absolute; bottom:.22rem;
  left:50%; transform:translateX(-50%); width:3.5px; height:3.5px; border-radius:50%;
  background:var(--pf-primary); }
.pf-cal-day.is-selected.has-event .pf-cal-n::after{ background:rgba(255,255,255,.92); }

/* ---- the month and year lists ----
   Same footprint as the day grid, so swapping between them never resizes the
   card under the pointer, and the list lands on the same optical centre the
   dates just left. */
.pf-cal-choice{ position:relative; display:grid; grid-template-columns:repeat(3,1fr);
  gap:.25rem; align-content:center; padding:.25rem .15rem; min-height:16.25rem; }
/* Years are a scroll, not a pagination: the list opens on the twelve around
   the current year and a flick reaches 1994. `height`, not `min-height`, or
   the grid would grow to fit a century instead of scrolling it.

   TWELVE AT A TIME, AND THE SAME TWELVE SHAPE AS THE MONTHS. The list used to
   show a ragged seven-and-a-bit rows: taller than the month grid, denser rows
   than the month grid, and always with a half-cut row at the bottom, which is
   what made it read as an endless column of numbers rather than a page you
   could take in. The window is now exactly one 3×4 page — four 2.75rem rows
   and the three .25rem gaps between them — so a year is chosen from twelve
   the same way a month is chosen from twelve.

   The 4.5rem of margin is not decoration: it restores the 16.25rem footprint
   the month grid occupies (2.25rem of centring space above and below its four
   rows), so switching between the two lists still does not resize the card. */
.pf-cal-choice-scroll{ align-content:start;
  min-height:0; height:11.75rem; padding-block:0; margin-block:2.25rem;
  overflow-y:auto;
  overscroll-behavior:contain; scrollbar-width:thin;
  scrollbar-color:var(--pf-border-strong) transparent;
  /* iOS momentum, and rows that come to rest aligned rather than half cut off
     by the edge of the list. `proximity`, never `mandatory`: a mandatory snap
     fights a long flick and can trap the scroll between two rows. */
  -webkit-overflow-scrolling:touch;
  scroll-snap-type:y proximity; }
/* `start`, not `center`: with only four rows in view, centring a row leaves a
   sliced row above and below it. Aligning to the top is what keeps a whole
   twelve on screen. */
.pf-cal-choice-scroll .pf-cal-choice-item{ scroll-snap-align:start; }
.pf-cal-choice-scroll::-webkit-scrollbar{ width:6px; }
.pf-cal-choice-scroll::-webkit-scrollbar-thumb{ background:var(--pf-border-strong); border-radius:3px; }
.pf-cal-choice-scroll::-webkit-scrollbar-track{ background:transparent; }
.pf-cal-choice-item{ display:grid; place-items:center; min-height:2.75rem; padding:.4rem .25rem;
  border:0; border-radius:10px; background:transparent; cursor:pointer; font:inherit;
  font-size:.8125rem; font-weight:500; color:var(--pf-text);
  transition:background-color var(--pf-dur-1) var(--pf-ease),
             color var(--pf-dur-1) var(--pf-ease); }
.pf-cal-choice-item:hover:not(.is-selected):not([aria-disabled="true"]){ background:var(--pf-surface-strong); }
.pf-cal-choice-item:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }
.pf-cal-choice-item.is-selected{ background:var(--pf-primary); color:#fff; font-weight:600; }
/* WHERE THE LIST IS POINTING, which is not the same as what has been chosen.
   On a month/year field these lists are the picker itself, so a filled cell is
   a claim that the value is set — filling today's month for somebody who has
   only picked a year told them "August 2019" was already chosen. This is the
   quieter state for the view's own position: outlined, never filled, and not
   announced as current. */
.pf-cal-choice-item.is-anchor{ box-shadow:inset 0 0 0 1px var(--pf-border-strong);
  color:var(--pf-text); font-weight:600; }
.pf-cal-choice-item[aria-disabled="true"]{ color:var(--pf-muted-2); opacity:.4; cursor:not-allowed; }

/* The time row keeps its rule: it is a different KIND of input from the grid
   above it, and that is the one place in this card where a line says something
   spacing cannot. */
.pf-cal-time{ flex:0 0 auto; display:flex; align-items:center; gap:.65rem; margin-top:.6rem;
  padding-top:.65rem; border-top:1px solid var(--pf-border); }
.pf-cal-time-lab{ font-size:.78rem; font-weight:500; color:var(--pf-muted); }
.pf-cal-timeinput{ flex:1; padding-top:.35rem; padding-bottom:.35rem; font-size:.85rem; }

/* ALWAYS DRAWN, and always the same two actions. A picker that sometimes has
   a confirm button and sometimes closes on its own is worse than either shape
   on its own — you have to work out which kind you are looking at before you
   know whether you have finished. Hence the divider: the row is now permanent,
   so a rule above it separates two real things rather than floating over an
   empty gap. */
/* `flex:0 0 auto` is not cosmetic here: it is the guarantee that Cancel and
   Done survive every viewport. The body above gives up its height first and
   scrolls; this row never does. */
.pf-cal-foot{ flex:0 0 auto; display:flex; align-items:center; gap:.4rem; margin-top:.55rem;
  padding:.55rem .1rem 0; border-top:1px solid var(--pf-border); }
.pf-cal-foot-gap{ flex:1 1 auto; }
/* The clamp notice. Quiet, not a warning: nothing went wrong — February simply
   has fewer days than January, and the picker is saying what it did about it. */
.pf-cal-note{ flex:0 0 auto; margin:.5rem .1rem 0; font-size:.75rem; line-height:1.45;
  color:var(--pf-muted); }
.pf-cal-foot [data-dp-done],
.pf-cal-foot [data-dp-cancel]{ min-height:2rem; padding:.35rem .9rem;
  border-radius:9px; font-size:.8125rem; }
/* A static header title at reduced precision: same metrics as the chips it
   replaces, so the header does not change height between modes. */
.pf-cal-title-static{ display:inline-flex; align-items:center; height:2rem;
  padding:0 .4rem; font-size:.875rem; font-weight:600; color:var(--pf-text); }
/* One quiet text action. A bordered button here would out-weigh the calendar
   it sits under, and it is not the thing the user came for. */
.pf-cal-link{ border:0; background:transparent; cursor:pointer; font:inherit; font-size:.78rem;
  font-weight:500; color:var(--pf-muted); padding:.35rem .5rem; margin-left:-.5rem;
  border-radius:8px;
  transition:background-color var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease); }
.pf-cal-link:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }
.pf-cal-link:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }

/* Small phones: trim the card's own padding rather than the cells, so the tap
   targets survive the narrower viewport. */
@media (max-width:359.98px){
  .pf-cal{ padding:.5rem; border-radius:16px; }
  .pf-cal-n{ width:2rem; height:2rem; }
}

/* ---- Breadcrumbs (dashboard + admin headers) ----
   Separators are CSS, not markup: the <ol> then contains only real locations
   and a screen reader never announces "slash". */
.pf-crumbs{ min-width:0; display:flex; align-items:center; }
.pf-crumbs-list{ display:flex; align-items:center; list-style:none; margin:0; padding:0;
  min-width:0; font-size:.875rem; color:var(--pf-muted); }
/* Intermediates give up width first; the page you are ON is the last thing to
   truncate, because it is the one crumb that answers "where am I". */
.pf-crumb{ display:flex; align-items:center; min-width:0; flex:0 1 auto; }
.pf-crumb.is-current, .pf-crumb-home, .pf-crumb-ellipsis{ flex:0 0 auto; }
.pf-crumb + .pf-crumb::before{ content:"/"; flex:0 0 auto; margin:0 .4rem;
  color:var(--pf-muted-2); }
.pf-crumb-link, .pf-crumb-text{ display:block; min-width:0;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* Intermediates get a ceiling so one long record name cannot push the current
   page out of the header; the full string stays in the title attribute. */
.pf-crumb-link{ max-width:12rem; color:var(--pf-muted); border-radius:6px;
  transition:color var(--pf-dur-1) var(--pf-ease); }
.pf-crumb-link:hover{ color:var(--pf-text); text-decoration:underline; }
/* The breadcrumb's home crumb is an icon, so it collapses to its 28px glyph
   and becomes the smallest tap target in the header. It clears WCAG 2.2's
   24px, but 28px under a thumb, at the very top of the screen, next to the
   drawer button, is a miss. Coarse pointers get a comfortable 40px box; the
   glyph does not move, only the area around it grows, so nothing shifts on
   desktop. */
@media (pointer: coarse) {
  .pf-crumb-link{ min-height:2.5rem; min-width:2.5rem; display:inline-flex;
    align-items:center; justify-content:center; }
}
.pf-crumb-link:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }
.pf-crumb-home .pf-crumb-link{ display:grid; place-items:center; width:1.75rem; height:1.75rem;
  max-width:none; margin-left:-.25rem; color:var(--pf-muted); }
.pf-crumb-home .pf-crumb-link:hover{ background:var(--pf-surface-soft); color:var(--pf-text);
  text-decoration:none; }
.pf-crumb-text{ max-width:16rem; color:var(--pf-text); font-weight:600; }
.pf-crumb-ellipsis{ color:var(--pf-muted-2); cursor:default; display:none; }

/* Narrow: fold everything between Home and the nearest parent. `.sr-only`, not
   `display:none` — see the comment in partials/breadcrumbs.php: the collapse
   is visual only, and the full trail stays in the accessibility tree. */
@media (max-width:639.98px){
  .pf-crumbs .pf-crumb-ellipsis.is-multi{ display:flex; }
  .pf-crumbs .pf-crumb-fold{ position:absolute; width:1px; height:1px; padding:0; margin:-1px;
    overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
  .pf-crumb-link{ max-width:8rem; }
  .pf-crumb-text{ max-width:12rem; }
}
/* Phones below ~480px cannot show Home + a parent + the current page and keep
   all three legible — measured, the parent and the current label both
   collapsed to four characters. The nearest parent folds away too, leaving
   `⌂ / … / Current` with the whole remaining line for the page you are on.
   Nothing is lost: every folded crumb is still in the accessibility tree, and
   the ellipsis title lists them for a pointer user. */
@media (max-width:479.98px){
  .pf-crumbs .pf-crumb-ellipsis{ display:flex; }
  .pf-crumbs .pf-crumb:not(.pf-crumb-home):not(.is-current):not(.pf-crumb-ellipsis){
    position:absolute; width:1px; height:1px; padding:0; margin:-1px;
    overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
}

/* Tablet band: the contextual panel is already showing the section name as its
   own title with the current item highlighted, so the breadcrumb is repeating
   it — and repeating it badly, crushed to "D.. / O" by the search field beside
   it. Hidden only while that panel is actually on screen: with the panel
   collapsed the breadcrumb is the sole indicator of where you are, so it comes
   straight back. */
@media (min-width:768px) and (max-width:1023.98px){
  html:not(.pf-ctx-collapsed) .pf-hdr-crumb{ display:none; }
}

/* The profile trigger drops to avatar-only in the two bands where the header
   is genuinely short of room. Below 640 that is the phone. From 768 to 1023 it
   is the tablet — counter-intuitive until you count the space: at 768 the nav
   shell claims 320px (72px rail + 248px panel), so the header has 448px to
   place a search field and two controls in, which is LESS than the same header
   gets on a 640px phone where the shell is a drawer. Viewport width and
   available width stop agreeing the moment a fixed-width sidebar exists. */
@media (max-width:639.98px), (min-width:768px) and (max-width:1023.98px){
  .pf-hdr-profile{ padding:.2rem; gap:0; max-width:none; }
  .pf-hdr-profile-name, .pf-hdr-profile-caret{ display:none; }
}

/* Print: CV-friendly */
@media print{
  .no-print{ display:none !important; }
  body{ background:#fff !important; color:#000 !important; }
}

/* (Tailwind display-utility parity layer moved to the very end of this
   file — it must come after ALL component display rules.) */

/* --- Billing cycle toggle + verification tick ------------------------ */
/* Chrome control, not a semantic accent — ink, like every other selected
   segmented control in the app (.pf-tab.active, .pf-page-link.active). */
.pf-cycle-active{ background:var(--pf-ink); color:#fff; }

/* ============== VERIFICATION BADGE (.pf-vtick) — ONE COMPONENT =========
   Every badge in the product comes out of Verification::tick(), and every
   name badge comes out of profile_name(). This is the only block that sizes
   or aligns them. There are no per-theme rules; adding one is a bug.

   WHY THIS WAS REWRITTEN. The name badge used to be `width:.62em`, sized
   against the heading it sat in. That reads as "proportional", and across one
   theme it is — but the twenty-one hero names range from 1rem (gallery, whose
   name lives in a 16px sticky bar and is not a heading at all) to clamp max
   4.2rem (editorial). A fixed em multiple turned that into a badge that
   rendered anywhere from ~10px to ~42px depending on which template the same
   profile was viewed in: a smudge at one end, a balloon at the other. Gallery
   already carried a hand-tuned override to escape its end of that range,
   which is the tell that the ratio itself was wrong.

   THE SIZE. `clamp(1rem, .62em, 1.5rem)` keeps the em relationship — the
   badge still tracks the name, and the fitter in app.js can still shrink a
   long name without the badge detaching — but bounds it to 16px–24px, which
   is the band modern verified badges actually live in (X ships 16px in the
   timeline and ~20–24px on a profile header). Every template now lands inside
   one 8px band instead of a 32px one, and the two extremes are the only
   places the clamp does any work at all:

     gallery    1rem name    → 16px   (was 10px, patched to 16.8px per-theme)
     devpro     1.5rem       → 16px   (was 14.9px)
     minimal    1.875rem     → 18.6px (unchanged — inside the band)
     elite      2.4rem       → 23.8px (unchanged)
     executive  2.9rem       → 24px   (was 28.8px)
     editorial  4.2rem       → 24px   (was 41.7px)

   THE GAP is a fraction of the BADGE, not of the name. Tied to the name it
   was .18em — 2.9px on gallery and 12px on editorial, so the badge drifted
   away from the word it belongs to exactly where it was already largest.

   ALIGNMENT is `vertical-align:middle` on the wrapper, which puts the badge's
   midpoint on the parent's baseline + half its x-height. That is optical
   centring that re-solves itself for any combination of badge size and name
   size, at any viewport, with no per-theme nudging — which is what the old
   per-theme `-.16em` was hand-approximating.

   SELF-CONTAINED ON PURPOSE. The layout below is also emitted as Tailwind
   utilities (`inline-flex align-middle`) by Verification::tick(). Declaring
   it here as well means the component does not depend on those utilities
   surviving a purge, and app.css loads after tailwind.css so these win. */
.pf-vtick{ display:inline-flex; align-items:center; justify-content:center;
  /* Not inert: in Verification::chip() the tick IS a flex item, and without
     this a long label squashes it. */
  flex:0 0 auto;
  vertical-align:middle; line-height:0; }
/* `block`, not `inline-block`: a flex item blockifies anyway, and the
   `vertical-align:-2px` that used to sit here was dead for the same reason —
   vertical-align does nothing to a flex item. Alignment is the wrapper's job,
   above. */
.pf-vtick svg{ display:block; flex:0 0 auto; }

/* The name badge — the one beside the display name in every theme hero. */
.pf-vtick .pf-tick-name{
  --pf-vbadge:clamp(1rem, .62em, 1.5rem);
  /* min-* as well as width/height: the size is a floor the component owns, not
     a hint a flex or grid parent may shrink. Together with the SVG's own
     viewBox this is what makes stretching impossible. */
  width:var(--pf-vbadge);     height:var(--pf-vbadge);
  min-width:var(--pf-vbadge); min-height:var(--pf-vbadge);
  margin-left:calc(var(--pf-vbadge) * .26); }

/* --- Profile display name (every theme hero, via profile_name()) -------
   INVARIANT: the full name and its verification tick always occupy ONE
   line, are never truncated and never get an ellipsis. When the name is
   wider than its container the TYPE shrinks, never the text.

   `.pf-name` stays `display:inline` on purpose — the gradient themes
   (.t-grad/.bp-grad) paint background-clip:text on the ancestor, and an
   inline-block child would break that clip. Sizing is em-relative so every
   theme keeps its own heading scale; the tick is already em-sized above,
   so it shrinks in step and stays attached to the last word. */
.pf-name{ white-space:nowrap; hyphens:none; }
/* No-JS / first-paint baseline for long names on narrow screens. Names that
   already fit are never touched, so no theme's visual identity changes.
   app.js ([data-name-fit]) then sets the exact size that fits. */
@media (max-width:640px){
  .pf-name-long { font-size:.84em; }
  .pf-name-xlong{ font-size:.68em; }
}
@media (max-width:400px){
  .pf-name-long { font-size:.74em; }
  .pf-name-xlong{ font-size:.58em; }
}

/* --- About: limited preview + Read more / Show less --------------------
   The ONLY shortened section on a profile. Line-clamped (not height-cut) so
   the preview always ends on a full line, in whatever type each theme uses.
   Neutral styling — colour comes from the theme's own .t-accent. */
.pf-about-text.is-clamped{
  display:-webkit-box; -webkit-box-orient:vertical;
  -webkit-line-clamp:var(--pf-about-lines,7); line-clamp:var(--pf-about-lines,7);
  overflow:hidden;
}
@media (max-width:640px){ .pf-about-text.is-clamped{ --pf-about-lines:5; } }
.pf-about-toggle{
  display:inline-flex; align-items:center; gap:.25rem; margin-top:.85rem;
  font-size:.875rem; font-weight:600; cursor:pointer;
  /* No underline: the caret beside the label already says this is a control,
     and the theme's own accent colour carries the affordance. */
  text-decoration:none;
}
.pf-about-toggle:hover{ opacity:.8; }
.pf-about-caret{ transition:transform var(--pf-dur-2) var(--pf-ease); }
[data-expanded="true"] .pf-about-caret{ transform:rotate(180deg); }
@media (prefers-reduced-motion:reduce){ .pf-about-caret{ transition:none; } }

/* ====================== MAX CUSTOMIZATION LAYER ======================
   Applied on public profiles via body classes + --pfc-* vars emitted by
   Custom::bodyAttrs(). Placed after the theme styles so equal-specificity
   rules win by source order; body.<class> descendant selectors out-rank
   the .theme-* rules. Values are server-validated hex only. */

/* Palette override (each class only present when that colour is set) */
body.pf-c-background{ background:var(--pfc-background); }
body.pf-c-text{ color:var(--pfc-text); }
body.pf-c-text h1, body.pf-c-text h2, body.pf-c-text h3{ color:var(--pfc-text); }
body.pf-c-card .t-card{ background:var(--pfc-card); }
body.pf-c-border .t-card{ border:1px solid var(--pfc-border); }
body.pf-c-button .pf-btn-primary{ background:var(--pfc-button); color:#fff; }
body.pf-c-link a:not(.pf-btn):not(.t-chip){ color:var(--pfc-link); }

/* Section density */
body.pf-density-compact .t-card{ padding:1rem; }
body.pf-density-compact main section{ margin-bottom:1.25rem; }
body.pf-density-spacious .t-card{ padding:2.5rem; }
body.pf-density-spacious main section{ margin-bottom:3.5rem; }

/* Card radius */
body.pf-radius-sharp .t-card{ border-radius:4px; }
body.pf-radius-round .t-card{ border-radius:22px; }
body.pf-radius-pill .t-card{ border-radius:32px; }

/* Button style (profile action buttons) */
body.pf-btnstyle-soft .pf-btn-primary{
  background:rgb(var(--pfc-button-rgb, var(--pf-primary-rgb)) / 14%);
  color:var(--pfc-button, var(--pf-primary)); box-shadow:none; }
body.pf-btnstyle-outline .pf-btn-primary{
  background:transparent; color:var(--pfc-button, var(--pf-primary));
  border:2px solid currentColor; box-shadow:none; }
body.pf-btnstyle-pill .pf-btn{ border-radius:999px; }

/* Social link style */
/* Social links follow the balanced rule on phones. Above 640px they stay
   content-sized pills — that is the themes' hero design and a link's own name
   is the right width for it. Below it they were a ragged wrap whose last line
   left dead space, so they become two equal columns with the trailing link
   taking the whole final row.
   Scoped OUT of `pf-social-icons`: that style renders label-less icon squares,
   and stretching a 1.15rem glyph across half the hero is not a button. */
.pf-social-row{ min-width:0; }
@media (max-width:639.98px){
  body:not(.pf-social-icons) .pf-social-row{
    --pf-bgrid-cols:2; --pf-bgrid-gap:.5rem;
    --pf-bgrid-basis:calc(
      (100% - (var(--pf-bgrid-cols) - 1) * var(--pf-bgrid-gap)) / var(--pf-bgrid-cols) - .05px);
    width:100%; gap:var(--pf-bgrid-gap); }
  body:not(.pf-social-icons) .pf-social-row > .pf-social-chip{
    flex:1 1 var(--pf-bgrid-basis); min-width:0; justify-content:center; }
  /* The label is the only elastic part of the chip, so it is what clips when a
     handle is long — the icon and the verification tick always stay visible. */
  body:not(.pf-social-icons) .pf-social-row > .pf-social-chip .pf-social-label{
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
}

body.pf-social-buttons .pf-social-chip{ padding:.55rem 1.1rem; border-radius:12px; font-weight:600; }
body.pf-social-icons .pf-social-label{ display:none; }
body.pf-social-icons .pf-social-chip{ padding:.55rem; border-radius:12px; }
body.pf-social-icons .pf-social-chip .pf-social-ico svg{ width:1.15rem; height:1.15rem; }

/* Icon sets (social icons) */
body.pf-icons-solid .pf-social-ico{ background:var(--pf-primary); color:#fff; border-radius:50%; padding:.3rem; }
body.pf-icons-rounded .pf-social-ico{ background:rgb(var(--pf-primary-rgb) / 12%);
  color:var(--pf-primary-ink); border-radius:8px; padding:.3rem; }
body.pf-icons-duotone .pf-social-ico{ background:rgb(var(--pf-accent-rgb) / 18%);
  color:var(--pf-primary-ink); border-radius:50%; padding:.3rem; }
body.pf-icons-duotone .pf-social-ico svg{ opacity:.85; }
body.pf-icons-thin .pf-social-ico svg{ stroke-width:1.1; }
body.pf-icons-brand .pf-social-ico{ color:#fff; border-radius:50%; padding:.3rem; background:#64748b; }
body.pf-icons-brand [data-platform="github"] .pf-social-ico{ background:#24292f; }
body.pf-icons-brand [data-platform="linkedin"] .pf-social-ico{ background:#0a66c2; }
body.pf-icons-brand [data-platform="twitter"] .pf-social-ico,
body.pf-icons-brand [data-platform="x"] .pf-social-ico{ background:#0f1419; }
body.pf-icons-brand [data-platform="facebook"] .pf-social-ico{ background:#1877f2; }
body.pf-icons-brand [data-platform="instagram"] .pf-social-ico{
  background:linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888); }
body.pf-icons-brand [data-platform="youtube"] .pf-social-ico{ background:#ff0000; }
body.pf-icons-brand [data-platform="dribbble"] .pf-social-ico{ background:#ea4c89; }
body.pf-icons-brand [data-platform="behance"] .pf-social-ico{ background:#1769ff; }
body.pf-icons-brand [data-platform="tiktok"] .pf-social-ico{ background:#010101; }
body.pf-icons-brand [data-platform="telegram"] .pf-social-ico{ background:#26a5e4; }
body.pf-icons-brand [data-platform="whatsapp"] .pf-social-ico{ background:#25d366; }
body.pf-icons-brand [data-platform="medium"] .pf-social-ico{ background:#191919; }

/* Verified badge style */
body.pf-badge-gold .pf-vtick svg path:first-child{ fill:#d4a017; }
body.pf-badge-mono .pf-vtick svg path:first-child{ fill:#334155; }

/* Theme: Editorial Max — magazine paper + ink */
.theme-editorial{ background:#faf9f6; color:#191919; }
.theme-editorial .ed-topline{ border-bottom:2px solid #191919; color:#6b6b6b; font-weight:600; }
.theme-editorial .ed-kicker{ color:#b3261e; font-weight:700; text-transform:uppercase;
  letter-spacing:.14em; font-size:.8rem; }
.theme-editorial .ed-name{ font-family:Georgia,'Times New Roman',serif; font-weight:700;
  font-size:clamp(2.6rem,7vw,4.2rem); line-height:1.02; letter-spacing:-.02em; }
.theme-editorial .ed-standfirst{ font-size:1.15rem; line-height:1.65; color:#44403c; }
.theme-editorial .ed-avatar{ border-radius:2px; box-shadow:8px 8px 0 #e7e4dc; }
.theme-editorial .ed-rule{ height:1px; background:#d9d5cb; }
.theme-editorial .t-card{ background:#fff; border:1px solid #e7e4dc; border-radius:2px; }
.theme-editorial .t-chip{ background:#efece4; color:#57534e; border-radius:2px; }
.theme-editorial .t-accent{ color:#b3261e; } .theme-editorial a{ color:#b3261e; }
.theme-editorial section > h2{ font-family:Georgia,serif; font-size:1.6rem; font-weight:700;
  border-bottom:2px solid #191919; padding-bottom:.4rem; letter-spacing:-.01em; }

/* Theme: Luxury Noir Max — near-black + gold hairlines */
.theme-noir{ background:#0d0d0f; color:#e8e6e1; }
.theme-noir .nr-frame{ border:1px solid #c8a951; border-radius:2px; }
.theme-noir .nr-avatar{ border-radius:1px; filter:saturate(.9); }
.theme-noir .nr-name{ font-family:Georgia,serif; font-weight:600;
  font-size:clamp(2.1rem,5vw,3rem); letter-spacing:.06em; text-transform:uppercase; }
.theme-noir .nr-title{ color:#c8a951; font-size:.85rem; font-weight:600;
  text-transform:uppercase; letter-spacing:.28em; }
.theme-noir .nr-rule{ width:3.5rem; height:1px; background:#c8a951; }
.theme-noir .t-card{ background:#141416; border:1px solid #26262a; border-radius:4px; }
.theme-noir .t-chip{ background:#1b1b1e; color:#d6c491; border:1px solid #2e2c26; border-radius:3px; }
.theme-noir .t-accent{ color:#c8a951; } .theme-noir a{ color:#d6c491; }
.theme-noir section > h2{ font-family:Georgia,serif; font-weight:600; font-size:1.25rem;
  text-transform:uppercase; letter-spacing:.18em; color:#e8e6e1; }
.theme-noir .pf-btn-ghost{ background:transparent; color:#e8e6e1; border-color:#3a3a3f; }
.theme-noir .pf-btn-ghost:hover{ background:#1b1b1e; border-color:#c8a951; }

/* Theme: Casebook — case-study portfolio (product & UX designers)
   Paper-and-ink on purpose: the accent tints numbers, rules and links only,
   never a text background, so any user colour stays legible and the only
   saturation on the page comes from the person's own screenshots. */
.theme-casebook{ background:#fbfbf9; color:#16171a; }
.theme-casebook .cb-shell{ max-width:66rem; margin-inline:auto; padding-inline:1.5rem; }
.theme-casebook .cb-top{ display:flex; align-items:center; justify-content:space-between;
  gap:1rem; flex-wrap:wrap; font-size:.72rem; font-weight:700; text-transform:uppercase;
  letter-spacing:.16em; color:#8a8a93; padding-bottom:.9rem;
  border-bottom:1px solid #e6e5e0; }
.theme-casebook .cb-open{ display:inline-flex; align-items:center; gap:.45rem; color:#16171a; }
.theme-casebook .cb-dot{ width:.45rem; height:.45rem; border-radius:50%;
  background:var(--pf-primary); box-shadow:0 0 0 3px rgb(var(--pf-primary-rgb) / 22%); }
.theme-casebook .cb-hero{ display:grid; gap:2rem; align-items:start; padding-top:2.75rem;
  grid-template-columns:1fr; }
.theme-casebook .cb-avatar{ width:88px; height:88px; border-radius:50%; object-fit:cover;
  box-shadow:0 0 0 1px #e6e5e0; }
.theme-casebook .cb-name{ font-size:clamp(2.4rem,6.5vw,4rem); font-weight:800;
  letter-spacing:-.035em; line-height:1.02; }
.theme-casebook .cb-role{ margin-top:.85rem; font-size:1.05rem; font-weight:600; color:var(--pf-primary); }
.theme-casebook .cb-lede{ margin-top:1rem; max-width:34rem; font-size:1.08rem;
  line-height:1.68; color:#4b4c53; }
.theme-casebook .cb-h2,
.theme-casebook section > h2{ font-size:.76rem; font-weight:700; text-transform:uppercase;
  letter-spacing:.2em; color:#8a8a93; padding-bottom:.7rem; margin-bottom:2rem;
  border-bottom:1px solid #e6e5e0; }
.theme-casebook .cb-cases{ display:flex; flex-direction:column; gap:3.5rem; }
.theme-casebook .cb-case{ display:grid; grid-template-columns:1fr; gap:.75rem; }
.theme-casebook .cb-case-num{ font-size:.8rem; font-weight:800; letter-spacing:.1em;
  font-variant-numeric:tabular-nums; color:var(--pf-primary-ink); }
.theme-casebook .cb-case-link{ display:grid; gap:1.5rem; grid-template-columns:1fr;
  color:inherit; text-decoration:none; }
.theme-casebook .cb-case-media{ overflow:hidden; border-radius:10px; background:#f1f0ec;
  border:1px solid #e6e5e0; }
.theme-casebook .cb-case-img{ display:block; width:100%; aspect-ratio:16/10; object-fit:cover;
  transition:transform var(--pf-dur-3) var(--pf-ease); }
.theme-casebook .cb-case-link:hover .cb-case-img{ transform:scale(1.02); }
.theme-casebook .cb-case-head{ display:flex; align-items:baseline; gap:.75rem; flex-wrap:wrap; }
.theme-casebook .cb-case-title{ font-size:1.5rem; font-weight:700; letter-spacing:-.02em; }
.theme-casebook .cb-case-year{ font-size:.8rem; font-variant-numeric:tabular-nums; color:#8a8a93; }
.theme-casebook .cb-case-sum{ margin-top:.6rem; font-size:.98rem; line-height:1.65; color:#4b4c53; }
.theme-casebook .cb-case-tags{ display:flex; flex-wrap:wrap; gap:.4rem; margin-top:1rem; }
.theme-casebook .cb-case-go{ display:inline-flex; align-items:center; gap:.3rem; margin-top:1.15rem;
  font-size:.88rem; font-weight:700; color:var(--pf-primary-ink); }
.theme-casebook .cb-case-link:hover .cb-case-go{ gap:.55rem; }
.theme-casebook .cb-case-go{ transition:gap var(--pf-dur-2) var(--pf-ease); }
.theme-casebook .t-card{ background:#fff; border:1px solid #e6e5e0; border-radius:10px; }
.theme-casebook .t-chip{ background:#f1f0ec; color:#5b5c63; border-radius:999px;
  padding:.2rem .6rem; font-size:.75rem; font-weight:600; }
.theme-casebook .t-accent{ color:var(--pf-primary); }
.theme-casebook a{ color:var(--pf-primary); }
@media (min-width:900px){
  .theme-casebook .cb-hero{ grid-template-columns:1fr auto; }
  .theme-casebook .cb-case{ grid-template-columns:3.5rem 1fr; gap:1.5rem; }
  /* Top-aligned, not centred: the media column is always taller than the
     text, so centring dropped every case title to the middle of its own
     image and read as a hole rather than as a column. */
  .theme-casebook .cb-case-link{ grid-template-columns:1.15fr 1fr; align-items:start; gap:2.25rem; }
  .theme-casebook .cb-case-text{ padding-top:.25rem; }
}

/* Theme: Horizon — one continuous career chronology.
   The year gutter is a fixed width with tabular figures so every year in the
   column sits on the same axis; that alignment is what makes the spine read
   as drawn rather than typed. */
.theme-horizon{ background:#fff; color:#15171c; }
.theme-horizon .hz-shell{ max-width:52rem; margin-inline:auto; padding-inline:1.5rem; }
.theme-horizon .hz-hero{ display:flex; align-items:center; gap:1.25rem; }
.theme-horizon .hz-avatar{ width:96px; height:96px; border-radius:20px; object-fit:cover;
  flex:0 0 auto; box-shadow:0 0 0 1px #e7e8ec; }
.theme-horizon .hz-name{ font-size:clamp(1.9rem,5vw,2.6rem); font-weight:800; letter-spacing:-.03em;
  line-height:1.1; }
.theme-horizon .hz-role{ margin-top:.3rem; font-weight:600; color:var(--pf-primary); }
.theme-horizon .hz-meta{ display:flex; flex-wrap:wrap; gap:.25rem 1rem; margin-top:.6rem;
  font-size:.85rem; color:#6b6e78; }
.theme-horizon .hz-meta span{ display:inline-flex; align-items:center; gap:.3rem; }
.theme-horizon .hz-lede{ margin-top:1.5rem; font-size:1.05rem; line-height:1.7; color:#4a4d57; }
.theme-horizon .hz-h2,
.theme-horizon section > h2{ font-size:1.05rem; font-weight:700; letter-spacing:-.01em;
  color:#15171c; margin-bottom:1.75rem; padding-bottom:.6rem; border-bottom:1px solid #ebecf0; }
.theme-horizon .hz-track{ position:relative; list-style:none; margin:0; padding:0; }
.theme-horizon .hz-ev{ position:relative; display:grid; gap:.5rem;
  grid-template-columns:1fr; padding-left:1.5rem; padding-bottom:2rem; }
/* The rail is drawn on the list item, not the list, so the last entry's line
   stops at its own node instead of running past the final card. */
.theme-horizon .hz-ev::before{ content:""; position:absolute; left:.3rem; top:.5rem; bottom:0;
  width:1px; background:#e7e8ec; }
.theme-horizon .hz-ev:last-child::before{ display:none; }
.theme-horizon .hz-node{ position:absolute; left:0; top:.35rem; width:.65rem; height:.65rem;
  border-radius:50%; background:#fff; border:2px solid var(--pf-primary); }
.theme-horizon .hz-ev.is-now .hz-node{ background:var(--pf-primary);
  box-shadow:0 0 0 4px rgb(var(--pf-primary-rgb) / 16%); }
.theme-horizon .hz-year{ display:flex; align-items:baseline; gap:.4rem;
  font-variant-numeric:tabular-nums; font-weight:700; font-size:.9rem; color:#15171c; }
.theme-horizon .hz-year small{ font-weight:600; font-size:.78rem; color:#8b8e98; }
.theme-horizon .hz-card{ padding:1.1rem 1.25rem; }
.theme-horizon .hz-ev-head{ display:flex; align-items:baseline; justify-content:space-between;
  gap:.75rem; flex-wrap:wrap; }
.theme-horizon .hz-ev-title{ font-size:1.05rem; font-weight:700; letter-spacing:-.01em; }
.theme-horizon .hz-kind{ font-size:.68rem; font-weight:700; text-transform:uppercase;
  letter-spacing:.1em; }
.theme-horizon .hz-org{ margin-top:.15rem; font-size:.9rem; font-weight:600; }
.theme-horizon .hz-where{ color:#8b8e98; font-weight:500; }
.theme-horizon .hz-desc{ margin-top:.6rem; font-size:.9rem; line-height:1.65; color:#4a4d57;
  white-space:pre-line; }
.theme-horizon .t-card{ background:#fff; border:1px solid #e7e8ec; border-radius:12px; }
.theme-horizon .t-chip{ background:#f4f5f7; color:#5b5e68; border-radius:999px;
  padding:.15rem .55rem; font-size:.75rem; font-weight:600; }
.theme-horizon .t-accent{ color:var(--pf-primary); }
.theme-horizon a{ color:var(--pf-primary); }
@media (min-width:720px){
  /* Year moves into a fixed gutter; the rail sits between gutter and card. */
  .theme-horizon .hz-ev{ grid-template-columns:4.5rem 1fr; gap:0 2rem; padding-left:0; }
  .theme-horizon .hz-year{ display:block; text-align:right; padding-top:.1rem; }
  .theme-horizon .hz-year small{ display:block; }
  .theme-horizon .hz-ev::before{ left:5.8rem; top:.6rem; }
  .theme-horizon .hz-node{ left:5.5rem; top:.45rem; }
}

/* ====================== MARKETING / EDITORIAL LAYER ======================
   Public + landing surfaces. Principles: white canvas, huge charcoal type,
   hairline borders instead of shadows, real product UI instead of abstract
   decoration, and colour ONLY where it means something. The brand gradient
   (--pf-grad) appears at most a handful of times per page: the logo, one
   hero detail, and the closing CTA. Everything else is neutral. */

/* Gradient button — reserved for the single closing CTA per page. */
.pf-btn-gradient{ background:var(--pf-grad-ink); color:#fff; border:none;
  box-shadow:0 1px 2px rgba(17,19,24,.12); }
.pf-btn-gradient:hover{ filter:brightness(1.06); box-shadow:0 6px 20px -6px rgba(124,58,237,.45); }
/* Inverse — white on the dark spotlight / gradient panels. */
.pf-btn-invert{ background:#fff; color:var(--pf-text); }
.pf-btn-invert:hover{ background:var(--pf-surface-strong); }
/* Outline-on-dark. Carries a real, visible box at rest (translucent fill + a
   border strong enough to read against the gradient) — the hairline version
   looked like a disabled link until you hovered it. */
.pf-btn-outline-invert{ background:rgba(255,255,255,.12); color:#fff;
  border-color:rgba(255,255,255,.55); }
.pf-btn-outline-invert:hover{ background:rgba(255,255,255,.22); border-color:#fff; }
.pf-btn-outline-invert:active{ background:rgba(255,255,255,.28); }

/* ---- Public nav item + active state --------------------------------
   Active = the same "selected" language the app chrome already uses for
   .pf-tab/.pf-ctx-link: a filled neutral pill with ink text. Recognisable at a
   glance without introducing a new colour. */
.pf-nav-item{ display:inline-flex; align-items:center; padding:.5rem .75rem;
  border-radius:8px; font-weight:500; color:var(--pf-muted);
  transition:color var(--pf-dur-1) var(--pf-ease), background-color var(--pf-dur-1) var(--pf-ease); }
.pf-nav-item:hover{ color:var(--pf-text); background:var(--pf-surface-strong); }
.pf-nav-item.is-active{ color:var(--pf-primary-ink); font-weight:600;
  background:var(--pf-surface-strong); }
.pf-navsheet-link.is-active{ color:var(--pf-primary-ink); font-weight:700; }
.pf-navsheet-link.is-active svg{ color:var(--pf-primary-ink); }

/* ---- Section divider ------------------------------------------------
   Two adjacent sections on the same white canvas get a hairline between them —
   whitespace alone left the page reading as one long undivided scroll. Sections
   that already sit on a tinted/dark surface bring their own edge and must NOT
   also get this, or the seam doubles. */
.pf-divide{ border-top:1px solid var(--pf-border); }

/* Section eyebrow — quiet uppercase label, not a coloured pill. */
.pf-eyebrow{ display:inline-flex; align-items:center; gap:.45rem; font-size:.72rem; font-weight:700;
  letter-spacing:.12em; text-transform:uppercase; color:var(--pf-muted); }
.pf-eyebrow svg{ color:var(--pf-accent); }
.pf-on-dark .pf-eyebrow{ color:rgba(255,255,255,.6); }

/* Announcement / trust pill — the one pill left on the page. */
/* Hero badge, with a rotating brand stroke.
   The pill itself does not move, resize or fade — only the hue travelling
   around its 1px edge. The interior stays plain white so the badge still
   reads as a quiet label rather than as decoration.

   The angle is a REGISTERED custom property: a plain `--x: 0deg` cannot be
   interpolated (custom properties animate as discrete strings), so without
   @property the conic gradient would jump rather than sweep. `--pf-pill-angle`
   is also declared normally on .pf-pill below, which matters — in a browser
   without @property support an unresolved var() would invalidate the whole
   conic-gradient and the stroke would vanish. With the fallback it simply
   holds still at 0deg. */
@property --pf-pill-angle{ syntax:"<angle>"; initial-value:0deg; inherits:false; }
@keyframes pf-pill-rotate{ to{ --pf-pill-angle:360deg; } }

/* At rest this is an ordinary quiet badge: white inside, one hairline grey
   stroke. The brand ring is the HOVER state — it fades in over the (now
   transparent) border and thickens slightly. Padding is symmetric; the old
   .35rem right side existed for a .pf-pill-tag chip that nothing renders. */
.pf-pill{ position:relative; --pf-pill-angle:0deg;
  display:inline-flex; align-items:center; gap:.5rem; font-size:.8rem; font-weight:600;
  color:var(--pf-text); background:var(--pf-surface); border:1px solid var(--pf-border);
  padding:.35rem .8rem; border-radius:999px; box-shadow:var(--pf-shadow-sm);
  transition:border-color var(--pf-dur-2) var(--pf-ease); }
.pf-pill:hover{ border-color:transparent; }
/* inset:-1px puts the ring on the border box, not the padding box, so the
   stroke lands exactly where the hairline sits and the badge's measured size
   never changes. The two-layer mask paints the interior out, leaving the frame.
   Paused at rest, so an invisible element costs nothing to render. */
.pf-pill::before{ content:""; position:absolute; inset:-1px; border-radius:inherit;
  padding:1px; pointer-events:none; opacity:0;
  background:conic-gradient(from var(--pf-pill-angle),
    var(--pf-brand-cyan), var(--pf-brand-violet), var(--pf-brand-pink),
    var(--pf-brand-yellow), var(--pf-brand-cyan));
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite:xor;
          mask-composite:exclude;
  animation:pf-pill-rotate 7s linear infinite;
  animation-play-state:paused;
  transition:opacity var(--pf-dur-2) var(--pf-ease); }
.pf-pill:hover::before{ opacity:1; padding:1.5px; animation-play-state:running; }
/* prefers-reduced-motion is handled globally further up (every animation is
   flattened to .001ms and one iteration), which parks this at its end state —
   a static stroke, no sweep. Nothing component-specific is needed. */
.pf-pill-tag{ display:inline-flex; align-items:center; gap:.25rem; font-size:.7rem; font-weight:700;
  background:var(--pf-surface-strong); color:var(--pf-muted); padding:.15rem .55rem; border-radius:999px; }

/* Hero canvas — near-white with a barely-there grid. No blobs, no glow. */
.pf-mk-hero{ position:relative; overflow:clip; background:var(--pf-bg); }
/* The hero canvas stays white. A brand wash here tinted the whole fold and
   made the page read heavier, which is the opposite of the intent — the
   colour belongs in the headline and the CTA, not behind them. */
.pf-mk-hero::before{ content:""; position:absolute; inset:0; pointer-events:none; z-index:1;
  background-image:linear-gradient(to right, rgba(17,19,24,.045) 1px, transparent 1px),
                   linear-gradient(to bottom, rgba(17,19,24,.045) 1px, transparent 1px);
  background-size:64px 64px;
  -webkit-mask-image:radial-gradient(70rem 40rem at 70% 10%, #000, transparent 70%);
  mask-image:radial-gradient(70rem 40rem at 70% 10%, #000, transparent 70%); }
.pf-mk-hero > *{ position:relative; }

/* Display type — tight, heavy, near-black, controlled measure. */
.pf-mk-title{ font-weight:800; letter-spacing:-.035em; line-height:1.05; color:var(--pf-text);
  text-wrap:balance; }
.pf-on-dark .pf-mk-title{ color:#fff; }
.pf-mk-title .pf-gradient-text{ background:var(--pf-grad);
  -webkit-background-clip:text; background-clip:text; color:transparent; }
/* Hero H1 — fluid so 320px never clips and 1440px never sprawls. */
.pf-h1{ font-size:clamp(2.25rem,5.2vw,4rem); }
.pf-h2{ font-size:clamp(1.75rem,3.4vw,2.75rem); }

/* Dark spotlight section — the editorial counterweight to all that white. */
/* Dark spotlight — deep charcoal base lit by soft, wide ambient glows. Three
   large low-opacity radials (violet high-right, blue low-left, a touch of warm
   peach) rather than one saturated wash: keeps it premium instead of neon, and
   large radii + low alpha avoid the banding a tight gradient produces. */
.pf-spotlight{ background:var(--pf-dark-base); color:#fff; position:relative; overflow:clip; }
.pf-spotlight::before{ content:""; position:absolute; inset:0; pointer-events:none;
  background:
    radial-gradient(60rem 34rem at 84% -8%,  var(--pf-glow-violet), transparent 64%),
    radial-gradient(52rem 30rem at 2% 104%,  var(--pf-glow-cyan),   transparent 62%),
    radial-gradient(40rem 26rem at 55% 118%, var(--pf-glow-pink),   transparent 60%),
    radial-gradient(34rem 22rem at 96% 96%,  var(--pf-glow-yellow), transparent 58%); }
.pf-spotlight > *{ position:relative; }
.pf-on-dark{ color:#fff; }
.pf-on-dark .pf-lede{ color:rgba(255,255,255,.68); }

/* ==================== HERO PRODUCT WINDOW (landing) ======================
   ClickUp-style hero composition: a Safari-grade browser window anchored to
   the hero's RIGHT edge — it bleeds to the viewport, its bottom sits on the
   hero baseline, and a CSS mask (never blur) melts its right + bottom edges
   into the page. Inside is a PREVIEW of a real profile — identity,
   availability, metrics, skills — a register quieter than the product
   itself: ghost actions, muted accents, no fills, no ambient motion.
   Nothing in here may compete with the hero headline or the page's CTAs.

   Tokens map 1:1 to design.md (the ClickUp-inspired system): colours from
   §2, 8px-grid spacing from §5, radius xl=16px ("hero components"),
   elevation L2 + hairline border per §6's "combine shadows with border
   strokes". Plus Jakarta Sans is already the platform font (--pf-font), so
   the design.md type face applies with nothing new loaded. #646464 is the
   darkest allowed secondary text; icons may drop to 55% of it, text never.
   Scoped: every rule lives under .hero-card-scene (the grid item in
   home.php). */
.hero-card-scene{
  --ds-text:#292D34;                 /* Dark Charcoal — headings, key numbers */
  --ds-text-2:#646464;               /* Dark Gray — secondary text, metadata */
  --ds-icon:rgba(100,100,100,.55);   /* very subtle icons — never for text */
  --ds-border:#E8E8E8;               /* Medium Gray — borders, dividers */
  --ds-surface:#F8F9FA;              /* Light Gray — chrome, subtle surfaces */
  --ds-track:#F0F0F0;                /* Off-White — progress-bar track */
  --ds-fill:#5D5D85;                 /* Muted Purple — the system's softer accent */
  --ds-ok-bg:#D4EDDA;                /* success badge, verbatim design.md §4 */
  --ds-ok-ink:#155724;
  --ds-ok-bd:#C3E6CB;
  --radius:16px;
  --shadow-card:rgba(0,0,0,.08) 0px 4px 12px 0px;   /* elevation L2 */
  position:relative; min-width:0; color:var(--ds-text-2);
}

/* Below lg the window is an ordinary centred block; the lg query at the end
   of this section turns it into the right-anchored, baseline-sitting bleed. */
.hcs-card{ position:relative; display:flex; flex-direction:column;
  width:min(34rem,100%); margin-inline:auto;
  background:#fff; border:1px solid var(--ds-border);
  border-radius:var(--radius); box-shadow:var(--shadow-card); overflow:hidden; }

/* Safari-grade chrome: macOS traffic lights, one minimal address pill. */
.hcs-frame{ display:flex; align-items:center; gap:12px; padding:8px 12px; min-width:0;
  background:var(--ds-surface); border-bottom:1px solid var(--ds-border); }
.hcs-dots{ display:flex; gap:6px; flex:0 0 auto; }
.hcs-dots span{ width:8px; height:8px; border-radius:999px; }
.hcs-dots span:nth-child(1){ background:#FF5F57; }
.hcs-dots span:nth-child(2){ background:#FEBC2E; }
.hcs-dots span:nth-child(3){ background:#28C840; }
/* width:0 (not merely min-width:0) is load-bearing: a flex item's min-content
   CONTRIBUTION is its content's min-content size, so the nowrap URL would set
   the scene's — and the hero grid's — floor to ~322px (the old hero window's
   lesson). A definite width:0 + flex-grow contributes zero. */
/* Safari's address field is a rounded RECT (design.md md=8px), not a
   marketing pill — the difference is what makes the chrome read "real". */
.hcs-url{ flex:1 1 0; width:0; min-width:0; max-width:20rem; margin-inline:auto;
  display:flex; align-items:center; justify-content:center; gap:4px;
  font-size:12px; font-weight:400; line-height:16px; color:var(--ds-text-2);
  background:#fff; border:1px solid var(--ds-border);
  border-radius:8px; padding:6px 12px;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.hcs-url svg{ width:12px; height:12px; flex:0 0 auto; color:var(--ds-icon); }
.hcs-frame-end{ width:36px; flex:0 0 auto; }  /* mirrors the dots so the pill centres */

/* Cover — a whisper of the system purple over Light Gray; scenery only. */
.hcs-cover{ position:relative; height:80px;
  background:
    radial-gradient(90% 170% at 14% 0%, rgba(102,71,240,.07), transparent 56%),
    radial-gradient(75% 150% at 82% -25%, rgba(118,18,250,.05), transparent 58%),
    var(--ds-surface); }
.hcs-cover::after{ content:""; position:absolute; inset:0; opacity:.03;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E"); }

/* 8px grid: 32px card padding, 24px between sections, 4–12px inside
   components. The 30rem cap pairs with the right mask stop — content must
   end inside the opaque zone; whitespace past it is what the fade eats. */
.hcs-body{ padding:0 32px 32px; }
.hcs-body > *{ max-width:30rem; }

.hcs-avatar{ position:relative; width:64px; height:64px; margin-top:-32px; }
.hcs-avatar img{ width:100%; height:100%; border-radius:50%; object-fit:cover;
  border:3px solid #fff; box-shadow:0 0 0 1px var(--ds-border); background:var(--ds-surface); }

/* Identity — heading face at 600: design.md wants 650, but the loaded PJS
   weights are static (400–800), and 600 is the compliant step down. No tick
   here — verification lives in the summary grid, stated once. */
.hcs-name{ display:flex; align-items:center; gap:6px; margin-top:16px;
  font-size:18px; font-weight:600; letter-spacing:-.2px; color:var(--ds-text); line-height:24px; }
.hcs-role{ margin-top:4px; font-size:14px; line-height:20px; color:var(--ds-text-2); }

.hcs-meta{ display:flex; align-items:center; flex-wrap:wrap; gap:12px; margin-top:12px;
  font-size:13px; line-height:20px; color:var(--ds-text-2); }
.hcs-meta-item{ display:inline-flex; align-items:center; gap:4px; }
.hcs-meta-item svg{ width:14px; height:14px; color:var(--ds-icon); flex:0 0 auto; }
/* Availability — design.md's success status badge, verbatim (§4). nowrap is
   load-bearing: in the summary grid's ~100px column the two-word text would
   otherwise wrap and split the pill onto two lines. */
.hcs-avail{ display:inline-flex; align-items:center; height:20px; padding:4px 8px;
  border-radius:3px; font-size:12px; font-weight:600; line-height:1; white-space:nowrap;
  color:var(--ds-ok-ink); background:var(--ds-ok-bg); border:1px solid var(--ds-ok-bd); }

/* Summary grid — Caption label over Emphasis value (design.md §3), two
   columns, hairline rows. Stacked under the identity by default; the ≥1280
   block turns it into the bordered rail on the window's right side. */
.hcs-summary{ display:grid; grid-template-columns:repeat(2,1fr); gap:12px 24px;
  margin-top:24px; align-content:start; }
.hcs-sum-cell{ min-width:0; }
.hcs-sum-cell:nth-child(n+3){ border-top:1px solid var(--ds-border); padding-top:12px; }
.hcs-sum-l{ display:block; font-size:12px; font-weight:400; line-height:16px;
  color:var(--ds-text-2); }
.hcs-sum-v{ display:flex; align-items:center; gap:4px; margin-top:2px;
  font-size:14px; font-weight:600; line-height:20px; color:var(--ds-text);
  font-variant-numeric:tabular-nums; }
/* The rating star inherits charcoal — an amber star would add a colour family. */
.hcs-sum-v svg{ width:12px; height:12px; fill:currentColor; flex:0 0 auto; }
/* Verification: stroke tick in the system's muted purple, not a filled glyph. */
.hcs-sum-ok svg{ width:14px; height:14px; fill:none; color:var(--ds-fill); }

/* Actions — ghost, compact, mockup-scale. The product's real buttons are
   44px (design.md touch targets); a preview must not look that tappable, or
   it competes with the page's CTAs. */
.hcs-actions{ display:flex; flex-wrap:wrap; gap:8px; margin-top:24px; }
.hcs-btn{ display:inline-flex; align-items:center; gap:6px; height:32px; padding:0 16px;
  border-radius:8px; font-size:13px; font-weight:500; line-height:20px; white-space:nowrap;
  color:var(--ds-text-2); background:transparent; border:1px solid var(--ds-border); }
.hcs-btn svg{ width:13px; height:13px; color:var(--ds-icon); flex:0 0 auto; }

/* Socials — very subtle: icon-only at 55% gray, no divider. */
.hcs-social{ display:flex; align-items:center; gap:16px; margin-top:24px; }
.hcs-soc{ display:inline-flex; color:var(--ds-icon); }
.hcs-soc svg{ width:16px; height:16px; }

/* Motion — the entrance rise only. No ambient loops: a static preview may
   not blink at the reader while the page's real CTAs are asking for a click.
   The animation opts IN to motion, and the global reduce kill at the top of
   this file is the second line of defence. */
@keyframes hcs-enter{ from{ opacity:0; transform:translateY(24px); } to{ opacity:1; transform:none; } }
@media (prefers-reduced-motion:no-preference){
  .hcs-card{ animation:hcs-enter .7s cubic-bezier(.2,.8,.2,1) both; }
}

@media (min-width:1024px){
  /* Anchor right + sit on the baseline. Negative margins on a STRETCHED grid
     item enlarge it beyond its row: margin-right bleeds the window past the
     shell's centring gutter and 2rem padding to the viewport edge, and
     margin-bottom:-6rem drops its bottom through the hero's pb-24 onto the
     section baseline. The hero's overflow:clip keeps all of it off the
     scrollbar. */
  .hero-card-scene{
    margin-right:calc(-1 * (max(0px, (100vw - 1280px) / 2) + 2rem));
    margin-bottom:-6rem;
    padding-left:3rem;   /* breathing room between the copy and the window */
    /* Soft white fade, right + bottom — a real alpha mask, never blur, so the
       window dissolves into the textured hero background instead of ending
       with a hard border. The stops are PAIRED with the body's content cap:
       content must end inside the opaque zone, so only window whitespace
       fades. Retune stops and cap together — every breakpoint block below
       that changes one changes both. */
    -webkit-mask-image:
      linear-gradient(to right,  #000 78%, transparent 100%),
      linear-gradient(to bottom, #000 76%, transparent 100%);
    -webkit-mask-composite: source-in;
            mask-image:
      linear-gradient(to right,  #000 78%, transparent 100%),
      linear-gradient(to bottom, #000 76%, transparent 100%);
            mask-composite: intersect;
  }
  .hcs-card{ width:auto; max-width:none; margin-inline:0; height:100%; }
  .hcs-body{ flex:1 1 auto; }
}
/* Narrow lg (1024–1279): the window is only ~575–720px wide — a tighter
   column + tighter gutter keeps the content edge ~25px inside the 78% stop
   at exactly 1024px. The summary grid stacks here, and the stacked content
   nearly fills the row-height window (~92%) — there is no bottom whitespace
   to fade. margin-top:-3rem buys 48px of headroom (the window rises into
   the hero's top padding), which puts the last grid row at ~85% and the
   88% stop safely past it. */
@media (min-width:1024px) and (max-width:1279px){
  .hero-card-scene{
    padding-left:1.5rem;
    margin-top:-3rem;
    -webkit-mask-image:
      linear-gradient(to right,  #000 78%, transparent 100%),
      linear-gradient(to bottom, #000 88%, transparent 100%);
    -webkit-mask-composite: source-in;
            mask-image:
      linear-gradient(to right,  #000 78%, transparent 100%),
      linear-gradient(to bottom, #000 88%, transparent 100%);
            mask-composite: intersect;
  }
  .hcs-body > *{ max-width:23rem; }
}
/* Wide lg (≥1280): the summary grid takes the window's empty right side as a
   bordered rail beside the identity. Content widens to 34rem, so the right
   mask stop rises with it: 88% of the 1280px window is ~638px, keeping the
   624px content edge inside the opaque zone at the tightest width. */
@media (min-width:1280px){
  .hcs-body > *{ max-width:34rem; }
  .hcs-split{ display:flex; gap:32px; }
  .hcs-main{ flex:1 1 auto; min-width:0; }
  .hcs-summary{ flex:0 0 224px; margin-top:0;
    border-left:1px solid var(--ds-border); padding-left:24px; }
  .hero-card-scene{
    -webkit-mask-image:
      linear-gradient(to right,  #000 88%, transparent 100%),
      linear-gradient(to bottom, #000 76%, transparent 100%);
    -webkit-mask-composite: source-in;
            mask-image:
      linear-gradient(to right,  #000 88%, transparent 100%),
      linear-gradient(to bottom, #000 76%, transparent 100%);
            mask-composite: intersect;
  }
}

/* Feature cards — flat, bordered, quiet. Hover lifts the border, not the box. */
.pf-feat{ background:var(--pf-surface); border:1px solid var(--pf-border);
  border-radius:var(--pf-radius-lg); padding:1.5rem;
  transition:border-color var(--pf-dur-2) var(--pf-ease), background-color var(--pf-dur-2) var(--pf-ease); }
.pf-feat:hover{ border-color:var(--pf-border-strong); background:var(--pf-surface-soft); }
a.pf-feat:hover{ background:var(--pf-surface); }

/* Tinted icon chips — the sanctioned place for accent colour.
   Three brand hues (sky / violet / pink) plus the logo's amber, each a tint
   surface under its AA-safe ink: 5.02, 4.64, 4.81, 4.55. `blue` and `teal`
   are kept as aliases of the sky chip rather than invented as extra hues —
   the logo has four stops and a fifth would be off-brand. */
.pf-ico-chip{ display:grid; place-items:center; width:2.5rem; height:2.5rem; border-radius:10px; }
.pf-ico-sky,
.pf-ico-blue,
.pf-ico-teal   { background:var(--pf-cyan-soft-2); color:var(--pf-cyan-ink); }
.pf-ico-violet { background:var(--pf-primary-soft-2); color:var(--pf-primary-ink); }
.pf-ico-pink   { background:var(--pf-pink-soft-2); color:var(--pf-pink-ink); }
.pf-ico-amber  { background:var(--pf-yellow-soft-2); color:var(--pf-yellow-ink); }
.pf-ico-emerald{ background:#F0FDF4; color:#15803D; }
.pf-ico-ink    { background:var(--pf-surface-strong); color:var(--pf-text); }
.pf-on-dark .pf-ico-chip{ background:rgba(255,255,255,.08); color:#fff; }

/* Numbered steps — ink counters, hairline rule between them. */
.pf-step{ display:flex; gap:1rem; align-items:flex-start; }
.pf-step-n{ flex:0 0 auto; display:grid; place-items:center; width:1.75rem; height:1.75rem;
  border-radius:999px; font-size:.75rem; font-weight:700; font-variant-numeric:tabular-nums;
  color:#fff; background:var(--pf-ink); }
.pf-on-dark .pf-step-n{ background:#fff; color:var(--pf-text); }

/* Stat block — real numbers only; never render this with invented data. */
.pf-stat-n{ font-size:clamp(1.75rem,3vw,2.5rem); font-weight:800; letter-spacing:-.03em;
  color:var(--pf-text); font-variant-numeric:tabular-nums; line-height:1; }
.pf-on-dark .pf-stat-n{ color:#fff; }
.pf-stat-l{ font-size:.8rem; font-weight:600; color:var(--pf-muted); margin-top:.4rem; }

/* Brand gradient panel — final CTA + auth brand column. Same surface, so the
   two biggest brand moments in the product are pixel-identical. */
/* The official gradient at FULL strength, with charcoal type on it rather than
   white. That is what lets the cyan and the yellow stay bright: white needs a
   dark ground, which meant darkening the ramp until it went muddy (the yellow
   end turns olive). Ink on the bright ramp measures 4.47 to 11.96 across the
   sweep, so the panel is both the exact brand colours and readable. */
.pf-brand-panel, .pf-cta-panel{ position:relative; overflow:clip; color:var(--pf-text);
  background:var(--pf-grad); }
/* A soft white bloom, which LIFTS the ramp — on a light panel that helps the
   ink rather than fighting it. */
.pf-brand-panel::before, .pf-cta-panel::before{ content:""; position:absolute; inset:0;
  pointer-events:none; background:
  radial-gradient(32rem 18rem at 88% -25%, rgba(255,255,255,.22), transparent 62%),
  radial-gradient(26rem 15rem at 6% 125%, rgba(255,255,255,.16), transparent 60%); }
.pf-brand-panel > *, .pf-cta-panel > *{ position:relative; }
.pf-cta-panel{ border-radius:var(--pf-radius-media); }

/* ==================== PRODUCT PREVIEW (profile_preview) ==================
   A miniature of a real profile.af page, shared by the hero mockup, the dark
   share card and the theme tiles.

   Structure: cover → avatar (own row, overlapping) → name → role → meta → chips.
   Everything below the cover shares ONE left edge (--pv-pad). Vertical rhythm is
   a small fixed scale, not ad-hoc margins, so the card never reads as crowded.
   Sizes are in px on purpose: this is a fixed-scale miniature, and inheriting a
   parent's font-size made it wobble between the hero and the dark card.
   Nothing here sets a fixed min-content width that could widen its grid track at
   320px (see the mock-url note above). */
/* Every painted property resolves from a custom property. That is what lets the
   theme showcase retint the SAME preview instance (colours, type, radii,
   buttons) by swapping one class — and, because custom properties on colour
   properties animate, the change crossfades for free with no layout shift. */
.pf-pv{
  --pv-pad:1rem; --pv-ava:3.25rem;
  --pv-radius:14px; --pv-chip-radius:7px; --pv-btn-radius:8px;
  --pv-font:inherit;
  --pv-name-weight:700; --pv-name-spacing:-.015em; --pv-name-transform:none;
  --pv-role-transform:none; --pv-role-spacing:0; --pv-role-weight:500;
  /* light defaults */
  --pv-bg:#fff; --pv-fg:var(--pf-text); --pv-muted:var(--pf-muted);
  --pv-muted-2:var(--pf-muted-2); --pv-outline:var(--pf-border);
  --pv-ava-bg:linear-gradient(135deg,#EEF1FF,#F4EEFB 55%,#FBEEF1);
  --pv-ava-fg:#4B5162;
  --pv-chip-bg:var(--pf-surface-soft); --pv-chip-fg:var(--pf-muted);
  --pv-chip-bd:var(--pf-border);
  --pv-accent:var(--pf-primary);
  --pv-ok:var(--pf-success); --pv-ok-halo:rgba(21,128,61,.14);
  --pv-btn-bg:var(--pf-ink); --pv-btn-fg:#fff;
  --pv-btn2-bg:transparent; --pv-btn2-fg:var(--pf-muted); --pv-btn2-bd:var(--pf-border);
  --pv-sep:var(--pf-border-strong);

  border-radius:var(--pv-radius); overflow:hidden; min-width:0;
  background:var(--pv-bg); color:var(--pv-fg); font-family:var(--pv-font);
  box-shadow:0 0 0 1px var(--pv-outline);
  transition:background-color .5s ease, color .5s ease, box-shadow .5s ease,
             border-radius .5s ease; }

.pf-pv-cover{ position:relative; width:100%; height:4.75rem; }
/* A whisper of depth on the banner so it reads as a designed cover, not a flat
   swatch: a top sheen fading to a faint floor shadow. Kept very low-alpha. */
.pf-pv-cover::after{ content:""; position:absolute; inset:0; pointer-events:none;
  background:linear-gradient(180deg, rgba(255,255,255,.16), transparent 45%,
             rgba(0,0,0,.05)); }

.pf-pv-body{ padding:0 var(--pv-pad) var(--pv-pad); }

/* Overlaps the cover by ~55% of its height, with a hairline ring rather than a
   thick border. The ring is the card background, so it re-tints with the theme.
   Holds a name monogram, lifted off the cover with a soft shadow. */
.pf-pv-avatar{ position:relative; z-index:1;
  width:var(--pv-ava); height:var(--pv-ava); margin-top:calc(var(--pv-ava) * -.55);
  margin-bottom:.625rem; border-radius:999px; display:grid; place-items:center;
  font-size:20px; font-weight:600; letter-spacing:-.01em;
  background:var(--pv-ava-bg); color:var(--pv-ava-fg);
  box-shadow:0 0 0 3px var(--pv-bg), 0 8px 18px -8px rgba(17,19,24,.28);
  transition:background-color .5s ease, color .5s ease, box-shadow .5s ease; }

.pf-pv-name{ font-size:17px; font-weight:var(--pv-name-weight); line-height:1.25;
  letter-spacing:var(--pv-name-spacing); text-transform:var(--pv-name-transform);
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  transition:color .5s ease, letter-spacing .5s ease; }
.pf-pv-role{ font-size:13.5px; font-weight:var(--pv-role-weight); line-height:1.35;
  margin-top:.1875rem; color:var(--pv-muted);
  letter-spacing:var(--pv-role-spacing); text-transform:var(--pv-role-transform);
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  transition:color .5s ease, letter-spacing .5s ease; }

/* One compact row: location · availability. */
.pf-pv-meta{ display:flex; align-items:center; flex-wrap:wrap; gap:.5rem;
  margin-top:.75rem; font-size:12px; font-weight:500; min-width:0;
  color:var(--pv-muted); transition:color .5s ease; }
.pf-pv-meta-item{ display:inline-flex; align-items:center; gap:.3rem; min-width:0; }
.pf-pv-meta svg{ color:var(--pv-muted-2); transition:color .5s ease; }
.pf-pv-meta-sep{ width:3px; height:3px; border-radius:999px; flex:0 0 auto;
  background:var(--pv-sep); }
.pf-pv-dot{ width:.4375rem; height:.4375rem; border-radius:999px; flex:0 0 auto;
  background:var(--pv-ok); box-shadow:0 0 0 2.5px var(--pv-ok-halo);
  transition:background-color .5s ease, box-shadow .5s ease; }
.pf-pv-avail{ color:var(--pv-ok); font-weight:600; transition:color .5s ease; }

.pf-pv-chips{ display:flex; flex-wrap:wrap; gap:.375rem; margin-top:.875rem; }
.pf-pv-chip{ font-size:11.5px; font-weight:500; line-height:1; padding:.375rem .5625rem;
  border-radius:var(--pv-chip-radius); white-space:nowrap;
  background:var(--pv-chip-bg); color:var(--pv-chip-fg);
  box-shadow:inset 0 0 0 1px var(--pv-chip-bd);
  transition:background-color .5s ease, color .5s ease, border-radius .5s ease,
             box-shadow .5s ease; }

/* ==================== DARK SHARE CARD ====================================
   Single consumer now: the analytics spotlight's dashboard card (home.php).
   Premium dark surface, one whisper of brand glow, hairline structure.
   Self-contained (its own palette), so it never depends on --pv-* vars. */
.pf-share-card{ position:relative; overflow:hidden;
  border-radius:var(--pf-radius-media); padding:1.5rem;
  background:#16181D; border:1px solid rgba(255,255,255,.08);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.05), 0 40px 80px -40px rgba(0,0,0,.8); }
@media (min-width:640px){ .pf-share-card{ padding:1.75rem; } }
.pf-share-card::before{ content:""; position:absolute; inset:0; pointer-events:none;
  background:radial-gradient(34rem 15rem at 88% -35%, rgba(123,104,238,.16), transparent 62%); }
.pf-share-card > *{ position:relative; }

.pf-share-head{ display:flex; align-items:center; justify-content:space-between; gap:1rem; }
.pf-share-tag{ display:inline-flex; align-items:center; gap:.35rem; white-space:nowrap;
  font-size:.6875rem; font-weight:600; color:#A6AEBB;
  background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.09);
  padding:.3rem .6rem; border-radius:999px; }
.pf-share-tag svg{ color:#8B919C; }

/* Quiet pills — deliberately NOT CTA buttons. Hairline ghost pills, muted
   text, no fill. Used by the dashboard card's quick actions (.pf-dash-acts). */
.pf-share-btn{ display:inline-flex; align-items:center; gap:.4rem;
  font-size:.8125rem; font-weight:600; line-height:1; color:#A6AEBB;
  padding:.5rem .8rem; border-radius:9px;
  border:1px solid rgba(255,255,255,.10); background:transparent; }
.pf-share-btn svg{ color:#8B919C; flex:0 0 auto; }

/* ================= DASHBOARD VARIANT (spotlight card) ====================
   A crop of views/dashboard/index.php — completion, counters, views trend, quick
   actions, in the product's own order — not an analytics illustration. Density
   comes from hairlines and tight type; the real page nests cards, which at this
   size would only add frames around three numbers.

   Violet is reserved for data (the completion fill and the plot) — every label,
   number, delta and icon stays in neutral ink, so the card gains information
   without gaining colour. That rule is why the counters' chips are a white wash
   rather than the dashboard's brand-tinted ones. */
.pf-dash-l{ display:block; font-size:.625rem; font-weight:600; line-height:1;
  letter-spacing:.09em; text-transform:uppercase; color:#8B919C; }
.pf-dash-block{ margin-top:1.35rem; padding-top:1.35rem;
  border-top:1px solid rgba(255,255,255,.08); }

.pf-dash-trend-head{ display:flex; align-items:center; justify-content:space-between; gap:1rem; }
/* Direction is carried by the arrow glyph, not by a green fill — the card keeps
   its one-accent rule, and the cue survives colourblind and forced-colours. */
.pf-dash-delta{ display:inline-flex; align-items:center; gap:.3rem; flex:0 0 auto;
  font-size:.75rem; font-weight:600; color:#A6AEBB; font-variant-numeric:tabular-nums; }
.pf-dash-delta svg{ color:#8B919C; flex:0 0 auto; }
/* Sized to the counters, not above them: the real page shouts this number in
   gradient text, which is exactly the infographic note the preview must not hit. */
.pf-dash-pct{ flex:0 0 auto; font-size:1.0625rem; font-weight:700; letter-spacing:-.02em;
  line-height:1; color:#F5F7FA; font-variant-numeric:tabular-nums; }
/* index.php fills its completion meter with .pf-skillbar — same component here,
   re-skinned for the dark surface and dropped to the card's data scale so it
   carries the same weight as the plot. Scoped to .pf-dash-card so the product's
   light 8px gradient default stays untouched everywhere else. */
.pf-dash-card .pf-skillbar{ height:5px; margin-top:.7rem; background:rgba(255,255,255,.08); }
.pf-dash-card .pf-skillbar > span{ background:rgba(123,104,238,.55); }
.pf-dash-spark{ display:block; width:100%; height:5rem; margin-top:.85rem; }

/* The dashboard's stat card, scaled: icon chip, then value over label. */
.pf-dash-kpis{ display:grid; grid-template-columns:repeat(3,1fr); margin-top:1.35rem;
  padding-top:1.35rem; border-top:1px solid rgba(255,255,255,.08); }
.pf-dash-kpi{ display:flex; align-items:center; gap:.55rem; min-width:0;
  padding-inline:.85rem; border-left:1px solid rgba(255,255,255,.08); }
.pf-dash-kpi:first-child{ padding-left:0; border-left:0; }
.pf-dash-kpi:last-child{ padding-right:0; }
.pf-dash-kpi-ico{ flex:0 0 auto; display:grid; place-items:center; width:1.75rem; height:1.75rem;
  border-radius:9px; color:#A6AEBB; background:rgba(255,255,255,.06);
  border:1px solid rgba(255,255,255,.07); }
.pf-dash-kpi-v{ display:block; font-size:1.0625rem; font-weight:700;
  letter-spacing:-.02em; line-height:1.1; color:#E9ECF2; font-variant-numeric:tabular-nums; }
/* The label is the first thing with slack in a three-up row, so it clips rather
   than widening the track or wrapping under the chip. */
.pf-dash-kpi .pf-dash-l{ margin-top:.3rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* Below ~560px the card is too narrow to seat a chip and a label side by side in
   a third of it — the chip wins on width and the label truncates to "PROFIL…".
   The chip is the decoration and the label is the information, so the chip goes
   and the labels wrap onto two lines instead of clipping. Must stay after the
   rule above: same specificity, so source order is what decides it. */
@media (max-width:560px){
  .pf-dash-kpi{ gap:0; padding-inline:.6rem; }
  .pf-dash-kpi-ico{ display:none; }
  .pf-dash-kpi .pf-dash-l{ white-space:normal; overflow:visible; }
}

/* Quick actions: the dashboard's list, wrapped into pills. They reuse
   .pf-share-btn, which is already the "quiet, not a CTA" pill on this card. */
.pf-dash-acts{ display:flex; flex-wrap:wrap; gap:.5rem; margin-top:.95rem; }

/* ---- Dark treatment: only re-assigns the variables above ---- */
.pf-pv-dark{
  --pv-bg:#14161A; --pv-fg:#F2F4F7; --pv-muted:#A6AEBB; --pv-muted-2:#767E8B;
  --pv-outline:rgba(255,255,255,.09);
  --pv-ava-bg:#242830; --pv-ava-fg:#8D95A1;
  --pv-chip-bg:rgba(255,255,255,.06); --pv-chip-fg:#C3CAD4;
  --pv-chip-bd:rgba(255,255,255,.08);
  --pv-ok:#3DD68C; --pv-ok-halo:rgba(61,214,140,.16);
  --pv-sep:#3A404A;
  --pv-btn-bg:#fff; --pv-btn-fg:#14161A;
  --pv-btn2-fg:#C3CAD4; --pv-btn2-bd:rgba(255,255,255,.16); }

/* ---- Compact tile — same anatomy, smaller scale ---- */
.pf-pv-sm{ --pv-pad:.75rem; --pv-ava:2.25rem; }
.pf-pv-sm .pf-pv-cover{ height:3rem; }
.pf-pv-sm .pf-pv-avatar{ margin-bottom:.5rem; }
.pf-pv-sm .pf-pv-name{ font-size:13px; }
.pf-pv-sm .pf-pv-role{ font-size:11px; margin-top:.125rem; }
.pf-pv-sm .pf-pv-chips{ margin-top:.5rem; gap:.25rem; }
.pf-pv-sm .pf-pv-chip{ font-size:10px; padding:.25rem .4375rem; border-radius:6px; }

/* ---- Large variant (theme showcase) ----
   height is FIXED. The three templates deliberately differ in layout (centred vs
   left, 2-up vs stacked cards, serif vs mono), which changes their natural
   height — pinning the box means those structural differences can never resize
   the card or shift the section. Content is anchored to the top and simply has
   more or less air beneath it. */
.pf-pv-lg{ --pv-pad:1.5rem; --pv-ava:4.5rem; height:22rem; }
.pf-pv-lg .pf-pv-cover{ height:6.5rem; }
.pf-pv-lg .pf-pv-avatar{ margin-bottom:.875rem; }
.pf-pv-lg .pf-pv-name{ font-size:22px; }
.pf-pv-lg .pf-pv-role{ font-size:15px; margin-top:.25rem; }
.pf-pv-lg .pf-pv-meta{ font-size:13px; margin-top:.875rem; }
.pf-pv-lg .pf-pv-chips{ margin-top:1rem; }
.pf-pv-lg .pf-pv-chip{ font-size:12.5px; padding:.4375rem .6875rem; }
@media (max-width:640px){
  .pf-pv-lg{ --pv-pad:1.125rem; --pv-ava:3.5rem; }
  .pf-pv-lg .pf-pv-cover{ height:5rem; }
  .pf-pv-lg .pf-pv-name{ font-size:19px; }
  .pf-pv-lg .pf-pv-role{ font-size:14px; }
}

/* Crossfading cover: one layer per theme, stacked. background-image cannot be
   transitioned, so the gradient is swapped by fading layers rather than by
   animating a single background — that is what makes the change a real
   crossfade instead of a hard cut. */
.pf-pv-cover-layer{ position:absolute; inset:0; opacity:0;
  transition:opacity .5s ease; }
.pf-pv-cover-layer.is-active{ opacity:1; }

/* Theme buttons + content blocks (large variant only) — these exist to show
   that a theme restyles ACTIONS and CARDS, not just colours. */
/* Two compact actions — a real public profile's primary/secondary. Left-aligned
   and auto-width so they sit in the card's crisp zone, clear of the right-edge
   fade. A hairline above separates them from the identity block. */
.pf-pv-actions{ display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1.125rem;
  padding-top:1.125rem; border-top:1px solid var(--pv-outline); }
.pf-pv-btn{ display:inline-flex; align-items:center; justify-content:center; gap:.4rem;
  font-size:12.5px; font-weight:600; line-height:1; padding:.625rem 1rem;
  border-radius:var(--pv-btn-radius); white-space:nowrap;
  background:var(--pv-btn-bg); color:var(--pv-btn-fg);
  transition:background-color .5s ease, color .5s ease, border-radius .5s ease; }
.pf-pv-btn-2{ background:var(--pv-btn2-bg); color:var(--pv-btn2-fg);
  box-shadow:inset 0 0 0 1px var(--pv-btn2-bd);
  transition:background-color .5s ease, color .5s ease, border-radius .5s ease,
             box-shadow .5s ease; }


/* ================= THEME DEFINITIONS (showcase) =========================
   Each mirrors the real theme's character in app.css (.theme-minimal /
   .theme-executive / .theme-brandpro) — palette, radii, type treatment — so the
   preview is an honest miniature of what the visitor actually gets. */

/* Minimal Professional (Free): white, ink, quiet geometry. */
.pv-t-minimal{
  --pv-bg:#fff; --pv-fg:#111827; --pv-muted:#5F6673; --pv-outline:#EEF0F3;
  --pv-chip-bg:#F3F4F6; --pv-chip-fg:#374151; --pv-chip-bd:transparent;
  --pv-accent:#111318; --pv-radius:14px; --pv-chip-radius:8px;
  --pv-btn-bg:#111318; --pv-btn-fg:#fff; }

/* Executive Minimal (Pro): editorial — tight name, uppercase tracked role,
   blue accent, squarer corners. */
.pv-t-executive{
  --pv-bg:#fff; --pv-fg:#0f172a; --pv-muted:#475569; --pv-muted-2:#94A3B8;
  --pv-outline:#E2E8F0;
  --pv-chip-bg:#F1F5F9; --pv-chip-fg:#475569; --pv-chip-bd:transparent;
  --pv-accent:var(--pf-primary-ink); --pv-radius:12px; --pv-chip-radius:6px; --pv-btn-radius:6px;
  --pv-btn-bg:var(--pf-primary-ink); --pv-btn-fg:#fff;
  --pv-name-weight:800; --pv-name-spacing:-.03em;
  --pv-role-transform:uppercase; --pv-role-spacing:.14em; --pv-role-weight:600; }
/* Colour only. A theme must never change a metric that drives height (font-size,
   line-height, padding) — that would resize the card and shift the section. */
.pv-t-executive .pf-pv-role{ color:var(--pf-primary-ink); }

/* ---- Editorial: magazine. Centred masthead, serif, hairline rules, square
   cards, red accent. A different LAYOUT, not just a different palette. ---- */
.pv-t-editorial{
  --pv-bg:#FAF9F6; --pv-fg:#191919; --pv-muted:#57534E; --pv-muted-2:#8A857C;
  --pv-outline:#E7E4DC;
  --pv-ava-bg:#EFECE4; --pv-ava-fg:#8A857C;
  --pv-chip-bg:#EFECE4; --pv-chip-fg:#57534E; --pv-chip-bd:transparent;
  --pv-accent:#B3261E; --pv-radius:2px; --pv-chip-radius:2px; --pv-btn-radius:2px;
  --pv-btn-bg:#191919; --pv-btn-fg:#FAF9F6;
  --pv-btn2-bd:#191919; --pv-btn2-fg:#191919;
  --pv-font:Georgia,"Times New Roman",serif;
  --pv-name-weight:700; --pv-name-spacing:-.01em;
  --pv-role-transform:uppercase; --pv-role-spacing:.18em; --pv-role-weight:700; }
/* Centred masthead: the avatar, name, role, meta and chips all centre, and the
   cards sit under a hairline rule — the anatomy of a magazine standfirst. */
.pv-t-editorial .pf-pv-body{ text-align:center; }
.pv-t-editorial .pf-pv-avatar{ margin-inline:auto; }
.pv-t-editorial .pf-pv-meta,
.pv-t-editorial .pf-pv-chips,
.pv-t-editorial .pf-pv-actions{ justify-content:center; }
.pv-t-editorial .pf-pv-role{ font-size:11px; color:#B3261E; }

/* ---- Developer Pro: dark terminal. Monospace throughout, square everything,
   stacked full-width cards, cyan/violet accent. ---- */
.pv-t-devpro{
  --pv-bg:#0A0F1A; --pv-fg:#F8FAFC; --pv-muted:#94A3B8; --pv-muted-2:#64748B;
  --pv-outline:#1E293B;
  --pv-ava-bg:#1E293B; --pv-ava-fg:#7DD3FC;
  --pv-chip-bg:#1E293B; --pv-chip-fg:#93C5FD; --pv-chip-bd:transparent;
  --pv-accent:#A78BFA; --pv-radius:6px; --pv-chip-radius:4px; --pv-btn-radius:4px;
  --pv-ok:#4ADE80; --pv-ok-halo:rgba(74,222,128,.16);
  --pv-sep:#334155;
  --pv-btn-bg:#A78BFA; --pv-btn-fg:#0A0F1A;
  --pv-btn2-fg:#93C5FD; --pv-btn2-bd:#334155;
  --pv-font:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  --pv-name-weight:700; --pv-name-spacing:0; }
/* Terminal cue + a single stacked column of cards (vs the 2-up grid elsewhere). */
.pv-t-devpro .pf-pv-name::before{ content:"~/ "; color:#A78BFA; font-weight:400; }
.pv-t-devpro .pf-pv-role::before{ content:"// "; color:#64748B; }

/* Personal Brand Premium: dark glassy mesh, gradient name, violet accent. */
.pv-t-brandpro{
  --pv-bg:#0A0A12; --pv-fg:#F5F3FF; --pv-muted:#C4B5FD; --pv-muted-2:#8B84B8;
  --pv-outline:rgba(255,255,255,.10);
  --pv-ava-bg:rgba(255,255,255,.06); --pv-ava-fg:#C4B5FD;
  --pv-chip-bg:rgba(255,255,255,.06); --pv-chip-fg:#DDD6FE;
  --pv-chip-bd:rgba(255,255,255,.10);
  --pv-accent:#A78BFA; --pv-radius:18px; --pv-chip-radius:999px; --pv-btn-radius:999px;
  --pv-ok:#5EEAD4; --pv-ok-halo:rgba(94,234,212,.18);
  --pv-sep:rgba(255,255,255,.18);
  --pv-btn-bg:#A78BFA; --pv-btn-fg:#14121F;
  --pv-btn2-fg:#DDD6FE; --pv-btn2-bd:rgba(255,255,255,.18);
  --pv-name-weight:900; --pv-name-spacing:-.03em;
  /* The dark mesh the real theme is known for. */
  background-image:
    radial-gradient(30rem 16rem at 12% -20%, rgba(139,92,246,.30), transparent 60%),
    radial-gradient(28rem 15rem at 92% 0%, rgba(37,99,235,.28), transparent 58%); }
.pv-t-brandpro .pf-pv-name{
  background:linear-gradient(120deg,#A78BFA,#60A5FA 55%,#5EEAD4);
  -webkit-background-clip:text; background-clip:text; color:transparent; }


@media (prefers-reduced-motion:reduce){
  .pf-pv, .pf-pv *, .pf-pv-cover-layer{ transition:none !important; }
}

/* Narrow phones: keep the miniature legible without letting it crowd. */
@media (max-width:400px){
  .pf-pv{ --pv-pad:.875rem; --pv-ava:2.875rem; }
  .pf-pv-name{ font-size:16px; }
  .pf-pv-role{ font-size:13px; }
}

/* Section action ("See all 20" / "Explore all") — see partials/section_action.php.
   The arrow nudges on hover; the button itself is already visible at rest. */
.pf-section-action svg{ transition:transform var(--pf-dur-2) var(--pf-ease); }
.pf-section-action:hover svg{ transform:translateX(2px); }

/* ==================== THEME SHOWCASE ROTATOR =============================
   Crossfade between three real template previews. Both slides are stacked in a
   fixed-ratio grid cell (grid-area:1/1), so switching cannot shift layout —
   there is no height change to animate around. JS only toggles .is-active. */
.pf-rot{ position:relative; display:grid; }
.pf-rot-slide{ grid-area:1/1; opacity:0; visibility:hidden;
  transition:opacity .5s ease, visibility 0s linear .5s; }
.pf-rot-slide.is-active{ opacity:1; visibility:visible; transition:opacity .5s ease; }
/* Dots — a 44px-tall transparent hit box with a 4px pill painted inside it.
   (background-clip:content-box does NOT work here: it clips the paint to the
   content box, which carries no border-radius, so the pills rendered as hard
   rectangles. The bar has to be its own rounded element.) */
.pf-rot-dots{ display:flex; gap:.4rem; align-items:center; }
.pf-rot-dot{ position:relative; width:1.6rem; height:2.75rem; padding:0; border:0;
  background:transparent; cursor:pointer; }
.pf-rot-dot::before{ content:""; position:absolute; left:0; right:0; top:50%;
  transform:translateY(-50%); height:.25rem; border-radius:999px;
  background:var(--pf-border-strong); transition:background-color var(--pf-dur-2) var(--pf-ease); }
.pf-rot-dot:hover::before{ background:var(--pf-muted-2); }
.pf-rot-dot.is-active::before{ background:var(--pf-ink); }
/* A rotating panel is decoration; if the user asked for less motion, show the
   first template and never move. */
@media (prefers-reduced-motion:reduce){
  .pf-rot-slide{ transition:none; }
}

/* ==================== MOBILE NAV OVERLAY ================================
   Full-screen white sheet (not a dropdown): logo + close on top, large rows,
   auth actions pinned at the bottom. Internally scrollable so it survives a
   667px-tall viewport. Focus is trapped while open and restored on close
   (see the [data-navsheet] controller in app.js). */
.pf-navsheet{ position:fixed; inset:0; z-index:100; display:flex; flex-direction:column;
  background:#fff; opacity:0; visibility:hidden; transform:translateY(-.5rem);
  transition:opacity var(--pf-dur-2) var(--pf-ease), transform var(--pf-dur-2) var(--pf-ease), visibility 0s linear var(--pf-dur-2); }
.pf-navsheet.is-open{ opacity:1; visibility:visible; transform:none;
  transition:opacity var(--pf-dur-2) var(--pf-ease), transform var(--pf-dur-2) var(--pf-ease); }
.pf-navsheet-head{ display:flex; align-items:center; justify-content:space-between;
  height:4rem; padding-inline:1.25rem; border-bottom:1px solid var(--pf-border); flex:0 0 auto; }
/* Every control in the sheet is 44px unconditionally — this surface only ever
   exists on touch, so it does not wait for the (pointer:coarse) layer. */
.pf-navsheet-head > a{ display:flex; align-items:center; min-height:44px; }
.pf-navsheet-head .pf-btn-icon{ width:44px; min-height:44px; }
/* Scrolling the sheet's list must not chain to the page behind it. */
.pf-navsheet-body{ overscroll-behavior:contain; }
.pf-navsheet-body{ flex:1 1 auto; overflow-y:auto; -webkit-overflow-scrolling:touch;
  padding:.5rem 1.25rem; }
.pf-navsheet-link{ display:flex; align-items:center; justify-content:space-between; gap:1rem;
  min-height:3.5rem; padding:.5rem 0; font-size:1.0625rem; font-weight:600;
  color:var(--pf-text); border-bottom:1px solid var(--pf-border); }
.pf-navsheet-link:active{ color:var(--pf-muted); }
.pf-navsheet-link svg{ color:var(--pf-muted-2); flex:0 0 auto; }
/* Auth actions pinned to the bottom; safe-area inset for gesture-bar phones. */
.pf-navsheet-foot{ flex:0 0 auto; display:grid; gap:.6rem; padding:1rem 1.25rem;
  padding-bottom:calc(1rem + env(safe-area-inset-bottom));
  border-top:1px solid var(--pf-border); background:#fff; }
.pf-navsheet-foot .pf-btn{ width:100%; min-height:3rem; font-size:.95rem; }
html.pf-navsheet-open, html.pf-navsheet-open body{ overflow:hidden; overscroll-behavior:none; }

/* ==================== AUTH PAGES ========================================
   Full-viewport white canvas with one soft pastel ambient wash at the top.
   Large radii + very low alpha = no banding, no neon. */
/* The auth screens fit the viewport — they must not scroll. Height is pinned to
   the dynamic viewport (100dvh accounts for mobile browser chrome), the middle
   column is the only flexible track, and it may scroll INTERNALLY as a last
   resort on very short screens rather than growing the page. */
.pf-auth{ position:relative; height:100dvh; max-height:100dvh; display:flex; flex-direction:column;
  background:var(--pf-bg); overflow:hidden; }
/* Ambient wash, left → right: warm peach → pink → violet → pale blue, fading to
   a clean white centre where the form actually sits. Wide radii + low alpha =
   no banding and no neon; nothing coloured sits behind the inputs. */
.pf-auth::before{ content:""; position:absolute; inset:0; pointer-events:none; z-index:0;
  background:
    radial-gradient(42rem 24rem at 4% -10%,  rgba(255,224,198,.80), transparent 60%),
    radial-gradient(40rem 24rem at 26% -12%, rgba(252,214,224,.75), transparent 60%),
    radial-gradient(44rem 26rem at 62% -14%, rgba(226,214,250,.70), transparent 60%),
    radial-gradient(46rem 26rem at 96% -10%, rgba(206,224,255,.80), transparent 62%); }
.pf-auth > *{ position:relative; z-index:1; }

/* The only flexible track. min-height:0 is required — a flex child defaults to
   min-height:auto and would refuse to shrink below its content, pushing the
   footer off-screen and re-introducing the page scroll we just removed. */
.pf-auth-main{ flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain;
  display:flex; align-items:center; justify-content:center;
  padding:1.5rem 1.25rem; }

.pf-auth-card{ width:100%; max-width:24rem; margin-inline:auto; }

/* Brand mark above the heading (rendered once, by layout_auth) */
.pf-auth-logo{ display:flex; justify-content:center; margin-bottom:1rem; }
.pf-auth-title{ font-size:1.75rem; font-weight:800; letter-spacing:-.025em; line-height:1.15;
  color:var(--pf-text); }
.pf-auth-sub{ font-size:.9rem; color:var(--pf-muted); margin-top:.5rem; }
.pf-auth-head{ margin-bottom:1.5rem; }

/* No card. The form sits directly on the ambient canvas — the white panel was
   a second surface on top of an already-white page, and it boxed the fields in
   for no reason. Kept as a class (rather than deleted from the views) so the
   four auth screens still share one structural hook. */
.pf-auth-panel{ background:transparent; border:0; border-radius:0;
  padding:0; box-shadow:none; }

@media (max-width:479.98px){
  .pf-auth-title{ font-size:1.5rem; }
}

/* Short viewports (iPhone SE / 375×667 with browser chrome): compact the fixed
   chrome — header, footer, logo, heading gaps — so the form fits outright and
   the internal scrollbar never has to appear. The fields themselves are never
   shrunk; only the surrounding air is. */
@media (max-height:740px){
  .pf-auth > footer{ padding-block:.875rem; }
  .pf-auth-main{ padding-block:1rem; }
  .pf-auth-logo{ margin-bottom:.625rem; }
  .pf-auth-logo .pf-brand-word-lg{ height:26px; }
  .pf-auth-title{ font-size:1.375rem; }
  .pf-auth-sub{ margin-top:.25rem; font-size:.85rem; }
  .pf-auth-head{ margin-bottom:1rem; }
  .pf-sep{ margin:.875rem 0; }
}

/* Very short viewports (320×568). Trims the last of the surrounding air so even
   the tallest screen — sign in, with Google below the form — fits outright. */
@media (max-height:600px){
  .pf-auth > footer{ padding-block:.625rem; font-size:.6875rem; }
  .pf-auth-main{ padding-block:.625rem; }
  .pf-auth-logo{ margin-bottom:.5rem; }
  .pf-auth-head{ margin-bottom:.875rem; }
  .pf-sep{ margin:.75rem 0; }
}
/* One link style for every auth secondary link (forgot password, account
   switch, back to sign in) so they never drift apart across the four screens. */
.pf-auth-link{ font-size:.875rem; font-weight:600; color:var(--pf-primary-ink); }
.pf-auth-link:hover{ text-decoration:underline; }

/* Separator above "Continue with Google" (which stays BELOW the email/password
   form — that order is deliberate and must not be flipped). */
.pf-sep{ display:flex; align-items:center; gap:.75rem; margin:1.25rem 0; }
.pf-sep::before,.pf-sep::after{ content:""; flex:1; height:1px; background:var(--pf-border); }
.pf-sep span{ font-size:.75rem; font-weight:500; color:var(--pf-muted); }

/* ========================= TOUCH TARGET LAYER ============================
   Coarse pointers (phones, tablets) get a ~44px practical minimum hit box.

   POSITION MATTERS: this must sit after every component's own width/height
   declaration. A media query adds no specificity, so an earlier
   `@media (pointer:coarse){ .pf-pw-toggle{height:2.5rem} }` loses to the later
   base `.pf-pw-toggle{height:2rem}` on source order — which is exactly how the
   first version of this layer silently did nothing.

   Dense admin tables would become unusable if every control grew to a full
   44px, so compact/icon controls land at 40px — comfortably tappable while
   keeping row density workable. Primary buttons get the full 44px. */
@media (pointer:coarse){
  /* Buttons */
  .pf-btn{ min-height:2.75rem; }                       /* 44px */
  .pf-btn-sm{ min-height:2.5rem; }                     /* 40px */
  .pf-btn-icon{ width:2.75rem; }
  .pf-btn-icon.pf-btn-sm{ width:2.5rem; }
  .pf-icon-btn{ width:2.5rem; height:2.5rem; }

  /* Segmented controls, pagination, links-as-buttons */
  .pf-tab{ min-height:2.5rem; }
  .pf-page-link{ min-width:2.75rem; height:2.75rem; }
  .pf-link-btn{ min-height:2.25rem; }
  .pf-back-btn{ min-height:2.5rem; }
  .pf-pw-toggle{ width:2.5rem; height:2.5rem; }

  /* Two-level nav shell (user dashboard + admin) */
  .pf-ctx-link{ min-height:44px; }
  .pf-ctx-collapse{ width:2.25rem; height:2.25rem; }
  .pf-ctx-expand{ width:44px; height:44px; }
  .pf-mrail-link{ min-height:48px; }
  .pf-hdr-iconbtn,.pf-hdr-profile{ min-height:44px; }

  /* Header account menu */
  .pf-avatar-btn{ width:2.75rem; height:2.75rem; }

  /* Billing cycle segmented toggle (Monthly / Annual) */
  [data-billing-cycle]{ min-height:2.5rem; }

  /* Date picker chrome. The day cells need nothing — they are 1/7th of a
     ≥276px grid, so they are already ~40px square — but the header arrows and
     the footer's text actions are deliberately small on a mouse. */
  .pf-cal-nav{ width:2.5rem; height:2.5rem; }
  .pf-cal-title{ height:2.5rem; }
  .pf-cal-link{ min-height:2.5rem; padding-inline:.65rem; }
  .pf-cal-foot [data-dp-done],
  .pf-cal-foot [data-dp-cancel]{ min-height:2.75rem; }
  .pf-cal-title-static{ height:2.5rem; }

  /* Nothing for the year list here any more. It used to need a touch override
     because its rows were 2.25rem — a comfortable size under a mouse and a
     miss under a thumb. Now that it is the same 3×4 page as the months, its
     rows are the months' 2.75rem at every pointer type, and a second set of
     metrics for touch would only make the two lists disagree. */

  /* Upload-details info trigger — a 14px icon is not a tap target. The icon
     stays 14px; only the hit box grows. */
  .pf-media-info{ min-width:2.5rem; min-height:2.5rem; align-items:center; justify-content:center;
    margin:-.5rem; }

  /* Gallery tile overlay controls sit ON the thumbnail, so the VISIBLE control
     stays small (a 44px chip would cover the image it belongs to). Instead the
     hit area is extended to ~44×44 with a transparent ::before that reaches
     outward into the tile's own padding — bigger target, same visual weight,
     and it never overlaps the neighbouring tile. */
  .pf-gtile-remove,.pf-gtile-handle{ width:1.75rem; height:1.75rem; }
  .pf-gtile-remove::before,.pf-gtile-handle::before{
    content:""; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
    width:44px; height:44px; border-radius:inherit; }
}

/* ================= TAILWIND DISPLAY-UTILITY PARITY LAYER =================
   MUST stay the LAST rules in this file. app.css loads after tailwind.css,
   so component rules like .pf-btn{display:inline-flex} out-cascade the
   equal-specificity display utilities (`hidden`, `md:hidden`,
   `sm:inline-flex`, …). Re-asserting them here, in Tailwind's own order
   (base first, then ascending breakpoints), restores exact utility
   semantics at the same 0-1-0 specificity — never solve this by raising
   component selector specificity (that broke md:hidden once already). */
.hidden{ display:none; }
@media (min-width:640px){
  .sm\:block{ display:block; }
  .sm\:flex{ display:flex; }
  .sm\:inline-flex{ display:inline-flex; }
  .sm\:hidden{ display:none; }
}
@media (min-width:768px){
  .md\:block{ display:block; }
  .md\:flex{ display:flex; }
  .md\:inline-flex{ display:inline-flex; }
  .md\:hidden{ display:none; }
}
@media (min-width:1024px){
  .lg\:block{ display:block; }
  .lg\:flex{ display:flex; }
  .lg\:inline-flex{ display:inline-flex; }
  .lg\:grid{ display:grid; }
  .lg\:hidden{ display:none; }
}
@media (min-width:1280px){
  .xl\:block{ display:block; }
  .xl\:flex{ display:flex; }
  .xl\:inline-flex{ display:inline-flex; }
  .xl\:grid{ display:grid; }
  .xl\:hidden{ display:none; }
}

/* =====================================================================
   CV BUILDER  (dashboard → CV builder)  +  ADMIN CV PANEL
   ---------------------------------------------------------------------
   The screen is a FLOW, not a settings page: choose a design · choose
   what goes on it · adjust how it looks · check it · download. Every
   family below exists to keep that flow readable at 320px and dense at
   1920px, using the existing tokens and nothing else.

     .pf-cv-grid  the two columns. Controls on the left, the A4 document
                  sticky on the right, capped at 440px — past that an A4
                  page gains no useful detail and only steals width from
                  the controls, which are what needs it.
     .pf-tpl      the picker card. The thumbnail inside it is an <iframe>
                  of the REAL CV document at true A4 width (delivered as
                  srcdoc, so four cards cost zero extra requests), scaled
                  by app.js. The whole card IS the submit button, which
                  makes "click anywhere to choose" structural rather than
                  a handler that has to catch every child element; the
                  frame therefore takes `pointer-events: none` or it
                  would swallow every click.
     .pf-sec      one row of the content list: grip, name, where it goes,
                  move buttons, visibility switch. Drag-and-drop is the
                  pointer shortcut; the buttons are what make it usable
                  from a keyboard. The placement select appears only on a
                  design that HAS a sidebar and only on the sections that
                  sidebar can hold.
     .pf-acc      the appearance accordion. Collapsed by default, each
                  group showing its CURRENT value, so the panel can be
                  read without being opened.
     .pf-cv-*     the preview frame, its states, the sticky phone action
                  bar and the public-source rows.
     .pf-admincv  the admin template table and its mobile card re-flow.
   ===================================================================== */

/* --- Page header ----------------------------------------------------- */
.pf-cv-head{ display:flex; flex-wrap:wrap; align-items:flex-end; justify-content:space-between;
  gap:.75rem; margin-bottom:1.25rem; }

/* A tighter card than the dashboard default. This screen carries five of
   them plus an A4 sheet; at the usual padding the controls column ran a
   third longer than the preview it is meant to sit beside. */
.pf-card-tight{ padding:1rem; }
@media (min-width:48rem){ .pf-card-tight{ padding:1.15rem; } }

/* --- Layout ---------------------------------------------------------- */
/* One column until there is genuinely room for an A4 page BESIDE the
   controls. That is not a device breakpoint: below ~62rem the preview
   column would be under 340px, at which point the sheet is too small to
   check and the controls are too narrow to use. */
/* THE LAYOUT ANSWERS TO ITS OWN WIDTH, NOT THE VIEWPORT'S.
   A container query, and this screen is the reason: at a 1024px viewport with
   the dashboard sidebar open, the CV builder gets about 520px. A viewport
   media query at 62rem happily split THAT into two columns, and the result was
   a controls column so narrow that "Modern Professional" wrapped onto four
   lines and every section row wrapped its own name. The page was responsive to
   a number that had nothing to do with the space it was given.

   PLACEMENT AND VISIBILITY LIVE HERE, NOT IN TAILWIND UTILITIES, for two more
   reasons learned on the same screen:

   1. ONE BREAKPOINT. Tailwind's `lg:` is 64rem and this layout turns at 60.
      Mixing them left a band in which the grid had two columns and nothing was
      placed into them, so the panel jumped to the top right and the preview to
      the bottom left. A layout must not have a width at which it is wrong.
   2. CASCADE ORDER. app.css loads AFTER tailwind.css, and `.pf-btn` sets
      `display:inline-flex` at the same specificity as `.max-lg\:hidden` sets
      `display:none` — so the utility silently lost and the header's Download
      button appeared on every phone, beside the sticky bar and the step-5
      button. Three download buttons on one screen. */
.pf-cv-shell{ container-type:inline-size; container-name:cvb; }
.pf-cv-grid{ display:grid; grid-template-columns:minmax(0,1fr); gap:1rem; align-items:start; }
.pf-cv-aside{ min-width:0; }
/* Narrow: the header button is redundant (the sticky bar carries it). */
.pf-cv-head-btn{ display:none; }

@container cvb (min-width:56rem){
  .pf-cv-grid{ grid-template-columns:minmax(0,1fr) minmax(0,390px); gap:1.25rem; }
  .pf-cv-c1  { grid-column:1; grid-row:1; }
  .pf-cv-c2  { grid-column:1; grid-row:2; }
  .pf-cv-c3  { grid-column:1; grid-row:3; }
  .pf-cv-side{ grid-column:2; grid-row:1 / span 3; position:sticky; top:5rem; }
  /* The sticky preview column already keeps a Download button permanently in
     view, so the phone bar goes and the header button comes back. */
  .pf-cv-bar{ display:none; }
  .pf-cv-head-btn{ display:inline-flex; }
}
@container cvb (min-width:76rem){
  .pf-cv-grid{ grid-template-columns:minmax(0,1fr) minmax(0,440px); }
}

/* Fallback for a browser without container queries: the same split, keyed off
   the viewport, at a width that clears the expanded dashboard sidebar. It is
   deliberately later than the container breakpoint — guessing high costs a
   wide browser one column, guessing low costs a narrow one a broken layout. */
@supports not (container-type: inline-size){
  @media (min-width:80rem){
    .pf-cv-grid{ grid-template-columns:minmax(0,1fr) minmax(0,400px); gap:1.25rem; }
    .pf-cv-c1  { grid-column:1; grid-row:1; }
    .pf-cv-c2  { grid-column:1; grid-row:2; }
    .pf-cv-c3  { grid-column:1; grid-row:3; }
    .pf-cv-side{ grid-column:2; grid-row:1 / span 3; position:sticky; top:5rem; }
    .pf-cv-bar{ display:none; }
    .pf-cv-head-btn{ display:inline-flex; }
  }
}

/* --- Step markers ---------------------------------------------------- */
.pf-step{ display:flex; align-items:flex-start; gap:.6rem; }
.pf-step-n{ flex:0 0 auto; width:1.5rem; height:1.5rem; border-radius:50%;
  display:grid; place-items:center; font-size:.72rem; font-weight:700; line-height:1;
  background:var(--pf-primary-soft, #F3F1FE); color:var(--pf-primary); }
.pf-step-title{ font-size:.9rem; font-weight:600; color:var(--pf-text); line-height:1.3; }
.pf-step .pf-help{ margin-top:.1rem; }

/* --- Notes (inline explanation, never a decorative box) -------------- */
.pf-note{ display:flex; align-items:flex-start; gap:.5rem; padding:.6rem .75rem;
  border:1px solid var(--pf-border); border-left-width:3px; border-radius:.5rem;
  font-size:.78rem; line-height:1.45; color:var(--pf-text); background:var(--pf-surface); }
.pf-note > svg{ flex:0 0 auto; margin-top:.1rem; }
.pf-note-info { border-left-color:var(--pf-primary); background:var(--pf-primary-soft, #F7F6FE); }
.pf-note-warn { border-left-color:var(--pf-warning); background:#FFFBEB; }
.pf-note-error{ border-left-color:var(--pf-danger);  background:#FEF2F2; }

/* --- Consequence list (the account-deletion warning) ------------------
   Used where a destructive flow has to state what it does BEFORE it asks
   for anything. A list rather than a paragraph on purpose: four short
   consequences with their own icons are read; the same four in prose are
   skipped, and the whole screen exists to not be skipped.
   `align-items:flex-start` plus a fixed icon column keeps the second line
   of a wrapped item aligned with the first, which is what makes it scan
   as a list at 320px rather than as ragged text.                        */
.pf-del-list{ display:flex; flex-direction:column; gap:.7rem; margin:0; padding:0; list-style:none; }
.pf-del-list > li{ display:flex; align-items:flex-start; gap:.6rem;
  font-size:.85rem; line-height:1.5; color:var(--pf-text); }
.pf-del-list > li > svg{ flex:0 0 auto; margin-top:.15rem; color:var(--pf-danger); }

/* --- Bulk action bar (.pf-bulkbar) ------------------------------------
   ONE bar for every multi-select screen. There were two: the approvals
   queue drew a bare flex row, /admin/users drew a tinted `.pf-card`, and
   they disagreed on spacing, on emphasis and on whether the selected
   count was even visible on a phone.

   The rules the component encodes:
     · the COUNT comes first and is never truncated — it is the only thing
       telling somebody how much damage a click is about to do;
     · destructive and constructive actions sit together at the end, so
       the eye lands on the count before the buttons;
     · there is ALWAYS a way out (.pf-bulkbar-x), because a selection the
       user cannot clear is a trap — reloading to escape is not an answer;
     · it wraps rather than scrolls, so nothing is ever off-screen at
       320px.                                                            */
.pf-bulkbar{ display:flex; flex-wrap:wrap; align-items:center; gap:.5rem;
  padding:.7rem .85rem; margin-bottom:1rem; border-radius:.75rem;
  border:1px solid rgb(var(--pf-primary-rgb) / 30%);
  background:rgb(var(--pf-primary-rgb) / 5%); }
.pf-bulkbar-count{ font-size:.85rem; font-weight:600; color:var(--pf-text); margin-right:.15rem; }
.pf-bulkbar-actions{ display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-left:auto; }
/* The reason box may take the whole row on a phone and share one on a
   desktop; `flex:1 1 12rem` is the whole behaviour, no breakpoint. */
.pf-bulkbar-field{ flex:1 1 12rem; min-width:0; }
.pf-bulkbar-x{ display:grid; place-items:center; width:2rem; height:2rem; border-radius:.5rem;
  border:0; background:transparent; color:var(--pf-muted); cursor:pointer; }
.pf-bulkbar-x:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }
.pf-bulkbar-x:focus-visible{ outline:2px solid var(--pf-primary); outline-offset:2px; }

/* --- Bulk-selection checkboxes on touch -------------------------------
   16px is the browser default and it is fine with a mouse: in an admin
   table the cell padding puts ~40px between neighbouring boxes, so they
   already satisfy WCAG 2.2 SC 2.5.8 through its spacing exception.

   A finger is not a mouse. Conforming and comfortable are different bars,
   and 16px is a miss on a phone whichever way the spec falls. Coarse
   pointers get the full 24px; every desktop rendering is untouched, so
   the dense table stays dense where density is affordable.               */
@media (pointer: coarse) {
  .bulk-cb, .bulk-all, .pf-purge-cb { width:1.5rem; height:1.5rem; }
}

/* --- Maintenance mark -------------------------------------------------
   The soft white tile behind the logo on the maintenance page.

   It lived in a <style> block inside the view, which is where the bug was:
   the outer glow was `0 0 0 10px color-mix(in srgb, var(--pf-brand-violet)
   5%, transparent)`, and that one declaration produced three different
   results across the browsers people actually use.

     - engines that premultiply (current Chrome, Firefox): a faint violet
       halo, which is the design;
     - engines that do NOT premultiply: 5% violet averaged with transparent
       BLACK, i.e. a grey-black ring — the reported artefact;
     - engines without color-mix() at all: the whole box-shadow declaration
       is invalid, so the tile loses its shadow entirely.

   Stated as plain rgba there is nothing left to disagree about, and it is
   now a component in the system rather than an override in one view. The
   colour is the brand violet at the alpha the correct rendering resolved
   to, so browsers that were already right are unchanged.                */
.pf-maint-mark{
  background:#fff;
  border:1px solid rgba(0,0,0,.06);
  box-shadow:
    0 1px 2px rgba(16,24,40,.04),
    0 10px 30px -12px rgba(16,24,40,.10),
    0 0 0 10px rgba(123,104,238,.05);
}

/* --- Account action row (settings → the three sensitive flows) --------
   The whole row is the link. That is one tab stop and one ~64px tap
   target per action, where a card with a button in the corner is two of
   the first and a ~32px version of the second. `min-width:0` on the text
   column is what lets a long address ellipsis instead of pushing the
   chevron off the card at 320px.                                        */
.pf-acct-row{ display:flex; align-items:center; gap:.85rem; padding:1rem 1.25rem;
  text-decoration:none; color:inherit; transition:background var(--pf-dur-2, .15s) var(--pf-ease, ease); }
.pf-acct-row:hover{ background:var(--pf-surface-soft); }
.pf-acct-row:focus-visible{ outline:2px solid var(--pf-primary); outline-offset:-2px; }
.pf-acct-row-icon{ flex:0 0 auto; width:2.25rem; height:2.25rem; border-radius:.625rem;
  display:grid; place-items:center; background:var(--pf-surface-strong); color:var(--pf-muted); }
.pf-acct-row-title{ display:block; font-weight:600; font-size:.9rem; line-height:1.3; }
.pf-acct-row-desc{ display:block; margin-top:.15rem; font-size:.78rem; line-height:1.45;
  color:var(--pf-muted); overflow-wrap:anywhere; }
.pf-acct-row--danger .pf-acct-row-icon{ background:#FEF2F2; color:var(--pf-danger); }
.pf-acct-row--danger .pf-acct-row-title{ color:var(--pf-danger); }

/* --- Template picker ------------------------------------------------- */
/* Two across on a phone, four from 30rem up. The cards are small on
   purpose: a picker is a glance, and all four have to be comparable
   without scrolling. At 320px two A4 thumbnails plus the gap still leave
   a readable name under each, which four across does not. */
.pf-tpl-grid{ display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:.55rem;
  margin-top:.85rem; }
/* Four across only once four A4 thumbnails are still large enough to tell
   apart — measured against the CARD's own width, which is a second container.
   It has to be the card and not the page: once the layout splits, the card is
   roughly 40% of the width the page has, and a grid keyed to the page would
   put four 90px thumbnails in a 350px phone column. Two across is the phone
   layout and four is everything else — measured, because at a 511px card the
   four-up thumbnails are 119px wide and the whole picker is 380px tall, while
   two-up makes each card 251px and the picker 750px. A picker that needs
   scrolling has stopped being a glance. */
.pf-cv-c1{ container-type:inline-size; container-name:cvtpl; }
@container cvtpl (min-width:27rem){
  .pf-tpl-grid{ grid-template-columns:repeat(4, minmax(0,1fr)); gap:.65rem; }
}
@supports not (container-type: inline-size){
  @media (min-width:64rem){ .pf-tpl-grid{ grid-template-columns:repeat(4, minmax(0,1fr)); gap:.65rem; } }
}
.pf-tpl-form{ display:flex; min-width:0; }
/* The card fills its grid cell so all four end on the same line — without it
   a template whose name wraps to two lines leaves the row visibly ragged. */
.pf-tpl-form > .pf-tpl{ flex:1 1 auto; }

.pf-tpl{ display:block; position:relative; width:100%; min-width:0; padding:.3rem;
  border:1px solid var(--pf-border); border-radius:.6rem; background:var(--pf-surface);
  cursor:pointer; text-align:center; text-decoration:none;
  transition:border-color .16s ease, box-shadow .16s ease, transform .16s ease; }
.pf-tpl:hover{ border-color:var(--pf-border-strong); transform:translateY(-2px);
  box-shadow:0 6px 18px rgba(17,19,24,.08); }
.pf-tpl:focus-visible{ outline:2px solid var(--pf-primary); outline-offset:2px; }
.pf-tpl.is-selected{ border-color:var(--pf-primary);
  box-shadow:0 0 0 3px var(--pf-primary-soft-2, #F2F0FD); }
/* Optimistic selection: the card commits the moment it is clicked, and only
   the preview waits for the server. */
.pf-tpl.is-busy{ opacity:.75; }

/* The paper. A4 proportions at every width, and it CLIPS — the frame inside
   is a full 297mm sheet, of which only page one is meant to show. */
.pf-tpl-paper{ display:block; position:relative; width:100%; aspect-ratio:1 / 1.414;
  overflow:hidden; border-radius:.25rem; background:#fff;
  border:1px solid var(--pf-border); }
/* A preview-only design gets NO plate and NO blur. The whole reason the card
   renders is that the design has to be evaluable; anything laid over it
   defeats that. The only difference is a hairline in the accent, so the eye
   can still group locked cards without losing the picture. */
.pf-tpl-paper.is-preview{ border-color:var(--pf-primary-border, #D9D2FB); }
/* 794px is A4 at the 96dpi the renderer is configured for. It is NEVER
   reflowed to the card width — a reflowed thumbnail would be a different
   layout from the PDF, which is the whole drift this architecture prevents.
   app.js sets the transform; the fallback scale keeps it sane before JS runs
   and if JS never runs at all. */
.pf-tpl-frame{ display:block; width:794px; height:1123px; border:0; background:#fff;
  transform-origin:top left; transform:scale(.16); pointer-events:none; }

.pf-tpl-name{ display:block; margin-top:.35rem; font-size:.7rem; font-weight:600;
  line-height:1.25; color:var(--pf-text); overflow-wrap:break-word; }
.pf-tpl-plan{ display:block; font-size:.62rem; font-weight:700; letter-spacing:.04em;
  text-transform:uppercase; color:var(--pf-muted); margin-top:.1rem; }

/* PRO / MAX. Small, on the paper's own top-left corner rather than under the
   name, so it reads as a property of the DESIGN and never competes with the
   selected tick on the opposite corner. It is aria-hidden — the card's own
   aria-label already says "Preview Ivory Rail (Max Plan)", and a badge read
   out a second time is noise. */
.pf-tpl-badge{ position:absolute; top:.5rem; left:.5rem; z-index:2;
  display:inline-flex; align-items:center; gap:.15rem;
  padding:.1rem .3rem; border-radius:.25rem;
  font-size:.55rem; font-weight:800; letter-spacing:.06em; line-height:1.4;
  color:#fff; background:rgba(30,33,40,.82);
  box-shadow:0 1px 3px rgba(17,19,24,.28); }
.pf-tpl-badge svg{ width:.55rem; height:.55rem; }

/* The card whose design is currently in the preview panel. Distinct from
   is-selected on purpose: one of these is what you are USING and the other is
   what you are LOOKING AT, and a user who cannot tell them apart is a user
   who thinks a preview changed their CV. Selected is a solid brand border
   plus a tick; previewing is a dashed ring and no tick. */
.pf-tpl.is-previewing{ border-color:var(--pf-primary); border-style:dashed;
  box-shadow:0 0 0 3px var(--pf-primary-soft-2, #F2F0FD); }
.pf-tpl-tick{ position:absolute; top:-.35rem; right:-.35rem; width:1.3rem; height:1.3rem;
  display:none; place-items:center; border-radius:50%; color:#fff;
  background:var(--pf-primary); box-shadow:0 2px 6px rgba(17,19,24,.25); }
.pf-tpl.is-selected .pf-tpl-tick{ display:grid; }
/* Locked is no longer a visual state of the card body — the thumbnail is the
   card. What is left is that it is a link rather than a submit button. */
.pf-tpl-blurb{ font-size:.78rem; line-height:1.5; color:var(--pf-muted);
  margin-top:.85rem; padding-top:.75rem; border-top:1px solid var(--pf-border); }

/* --- Content list: section rows -------------------------------------- */
.pf-sec-list{ list-style:none; margin:.85rem 0 0; padding:0;
  display:flex; flex-direction:column; gap:.3rem; }
.pf-sec{ display:flex; align-items:center; gap:.5rem; min-width:0;
  padding:.4rem .5rem; border:1px solid var(--pf-border); border-radius:.5rem;
  background:var(--pf-surface); transition:border-color .14s ease, opacity .14s ease; }
.pf-sec.is-off{ opacity:.55; }
.pf-sec.is-empty .pf-sec-meta{ font-style:italic; }
.pf-sec.is-dragging{ opacity:.35; }
.pf-sec.is-over{ border-color:var(--pf-primary); }
.pf-sec-grip{ flex:0 0 auto; color:var(--pf-muted); cursor:grab; line-height:0; }
.pf-sec-grip:active{ cursor:grabbing; }
.pf-sec-main{ flex:1 1 auto; min-width:0; }
.pf-sec-name{ display:block; font-size:.78rem; font-weight:600; color:var(--pf-text); line-height:1.25; }
.pf-sec-meta{ display:block; font-size:.66rem; color:var(--pf-muted); line-height:1.3; }

/* WHERE A SECTION GOES — sidebar or main column.
   Deliberately the quietest control in the row: no border until it is hovered
   or focused, so a list of twelve rows does not read as a wall of dropdowns
   while the two things that matter (the name and the on/off switch) stay the
   loudest. It sits before the move buttons because it answers "where", and the
   move buttons answer "in what order" — same question, coarse then fine.
   `draggable=false` is load-bearing: the row above it is draggable, and without
   this a press on the select starts a drag in WebKit instead of opening it. */
.pf-sec-place{ flex:0 0 auto; display:inline-flex; }
.pf-sec-place-sel{
  appearance:none; -webkit-appearance:none; -moz-appearance:none;
  font-size:.66rem; line-height:1.2; font-weight:500; color:var(--pf-muted);
  padding:.2rem 1.15rem .2rem .4rem; border:1px solid transparent; border-radius:.375rem;
  background-color:transparent; cursor:pointer;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23888f9b' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6.5 8 10.5l4-4'/%3E%3C/svg%3E");
  background-repeat:no-repeat; background-position:right .25rem center; background-size:.7rem;
}
.pf-sec-place-sel:hover{ color:var(--pf-text); border-color:var(--pf-border); background-color:var(--pf-surface-soft); }
.pf-sec-place-sel:focus-visible{ outline:2px solid var(--pf-primary); outline-offset:1px; }
.pf-sec.is-off .pf-sec-place{ visibility:hidden; }

.pf-sec-moves{ flex:0 0 auto; display:flex; flex-direction:column; gap:1px; }
.pf-sec-move{ display:grid; place-items:center; width:1.35rem; height:1rem; padding:0;
  border:0; background:transparent; color:var(--pf-muted); cursor:pointer; border-radius:.2rem; }
.pf-sec-move:hover{ color:var(--pf-text); background:var(--pf-surface-soft); }
.pf-sec-move:focus-visible{ outline:2px solid var(--pf-primary); outline-offset:1px; }

/* A self-contained switch: the global .pf-switch-track rule is driven by
   Tailwind's `peer` utilities, which these rows do not use. */
.pf-switch{ flex:0 0 auto; position:relative; display:inline-flex; cursor:pointer;
  align-items:center; }
.pf-switch input{ position:absolute; width:1px; height:1px; opacity:0; }
.pf-switch-track{ display:block; width:1.95rem; height:1.1rem; border-radius:999px;
  background:var(--pf-border-strong); transition:background-color .15s ease; }
.pf-switch-dot{ display:block; width:.8rem; height:.8rem; margin:.15rem; border-radius:50%;
  background:#fff; transition:transform .15s ease; }
.pf-switch input:checked ~ .pf-switch-track{ background:var(--pf-primary); }
.pf-switch input:checked ~ .pf-switch-track .pf-switch-dot{ transform:translateX(.85rem); }
.pf-switch input:disabled ~ .pf-switch-track{ opacity:.5; }
.pf-switch input:disabled{ cursor:default; }
.pf-switch input:focus-visible ~ .pf-switch-track{ outline:2px solid var(--pf-primary); outline-offset:2px; }

.pf-cv-config-foot{ display:flex; align-items:center; flex-wrap:wrap; gap:.5rem; margin-top:.75rem; }
.pf-cv-saved{ margin-left:auto; font-size:.7rem; color:var(--pf-muted); }

/* --- Appearance accordion -------------------------------------------- */
.pf-acc-group{ margin-top:.85rem; border:1px solid var(--pf-border); border-radius:.6rem;
  overflow:hidden; }
.pf-acc + .pf-acc{ border-top:1px solid var(--pf-border); }
.pf-acc-head{ display:flex; align-items:center; gap:.6rem; padding:.6rem .7rem;
  cursor:pointer; list-style:none; background:var(--pf-surface);
  transition:background .14s ease; }
.pf-acc-head::-webkit-details-marker{ display:none; }
.pf-acc-head:hover{ background:var(--pf-surface-soft); }
.pf-acc-head:focus-visible{ outline:2px solid var(--pf-primary); outline-offset:-2px; }
.pf-acc-ico{ flex:0 0 auto; color:var(--pf-muted); line-height:0; }
.pf-acc-text{ flex:1 1 auto; min-width:0; }
.pf-acc-title{ display:block; font-size:.78rem; font-weight:600; color:var(--pf-text); line-height:1.25; }
/* The current value, in the closed state. This is what makes a collapsed
   accordion readable rather than a row of doors. */
.pf-acc-sum{ display:block; font-size:.66rem; color:var(--pf-muted); line-height:1.35;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pf-acc[open] .pf-acc-sum{ display:none; }
.pf-acc-chev{ flex:0 0 auto; color:var(--pf-muted); line-height:0; transition:transform .16s ease; }
.pf-acc[open] .pf-acc-chev{ transform:rotate(180deg); }
.pf-acc[open] .pf-acc-head{ background:var(--pf-surface-soft); }
.pf-acc-body{ padding:.15rem .7rem .85rem; display:flex; flex-direction:column; gap:.8rem;
  background:var(--pf-surface); }

.pf-field{ display:block; min-width:0; }
.pf-field-label{ display:block; font-size:.72rem; font-weight:600; color:var(--pf-text);
  margin-bottom:.3rem; }
.pf-field-row{ display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:.7rem; }
@media (max-width:26rem){ .pf-field-row{ grid-template-columns:minmax(0,1fr); } }

.pf-check{ display:flex; align-items:flex-start; gap:.45rem; font-size:.76rem;
  color:var(--pf-text); cursor:pointer; line-height:1.4; }
.pf-check input[type="checkbox"]{ flex:0 0 auto; margin-top:.12rem; }
.pf-check.is-locked{ cursor:default; color:var(--pf-muted); }
.pf-check-grid{ display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:.35rem .7rem; }
@media (max-width:26rem){ .pf-check-grid{ grid-template-columns:minmax(0,1fr); } }

/* A segmented control. Borders go on the SECOND and later options so the
   group never ends on a stray divider. It WRAPS rather than overflowing:
   at 320px a three-option group inside the accordion is a few pixels from
   the edge, and a horizontal scrollbar on a radio group is not a control. */
.pf-seg{ display:inline-flex; flex-wrap:wrap; border:1px solid var(--pf-border);
  border-radius:.5rem; overflow:hidden; max-width:100%; }
.pf-seg-opt{ padding:.35rem .65rem; font-size:.73rem; color:var(--pf-muted);
  cursor:pointer; user-select:none; white-space:nowrap;
  transition:background .14s ease, color .14s ease; }
.pf-seg-opt + .pf-seg-opt{ border-left:1px solid var(--pf-border); }
.pf-seg-opt:hover{ background:var(--pf-surface-soft); }
.pf-seg-opt.is-on{ background:var(--pf-primary); color:#fff; }
.pf-seg-opt:focus-within{ outline:2px solid var(--pf-primary); outline-offset:-2px; }

/* --- Colour swatches -------------------------------------------------- */
.pf-swatches{ display:flex; flex-wrap:wrap; gap:.35rem; }
.pf-swatch{ display:inline-flex; align-items:center; gap:.3rem;
  padding:.25rem .6rem .25rem .3rem; border:1px solid var(--pf-border);
  border-radius:999px; cursor:pointer; font-size:.7rem; color:var(--pf-muted);
  transition:border-color .14s ease, background .14s ease, color .14s ease; }
.pf-swatch:hover{ border-color:var(--pf-border-strong); }
.pf-swatch.is-on{ border-color:var(--pf-primary); background:var(--pf-primary-soft, #F3F1FE);
  color:var(--pf-text); font-weight:600; }
.pf-swatch:focus-within{ outline:2px solid var(--pf-primary); outline-offset:2px; }
.pf-swatch-chip{ display:block; width:.85rem; height:.85rem; border-radius:50%;
  box-shadow:inset 0 0 0 1px rgba(17,19,24,.12); }
.pf-swatch-chip2{ margin-left:-.45rem; }

/* The native colour inputs. A native picker is used on purpose: it is the
   one the person's own OS already taught them, it is keyboard-accessible
   for free, and every safety rule that matters runs on the server anyway. */
.pf-color-row{ display:flex; flex-wrap:wrap; gap:.5rem; }
.pf-color{ display:inline-flex; align-items:center; gap:.45rem; font-size:.72rem;
  color:var(--pf-text); cursor:pointer; padding:.3rem .6rem .3rem .35rem;
  border:1px solid var(--pf-border); border-radius:.5rem; background:var(--pf-surface); }
.pf-color:hover{ border-color:var(--pf-border-strong); }
.pf-color:focus-within{ outline:2px solid var(--pf-primary); outline-offset:2px; }
.pf-color input[type="color"]{ width:1.6rem; height:1.6rem; padding:0; border:0;
  border-radius:.35rem; background:none; cursor:pointer; }
.pf-color input[type="color"]::-webkit-color-swatch-wrapper{ padding:0; }
.pf-color input[type="color"]::-webkit-color-swatch{ border:1px solid rgba(17,19,24,.14);
  border-radius:.3rem; }

/* --- Radio CARDS (public download source, CV branding visibility) ------
   The whole card is the control. It is a <label>, so the entire box already
   activates the input inside it — `cursor:pointer` on the container is what
   tells you that, and the states below are what confirm it happened.
   `padding:1rem` and a two-column grid keep the mark, the title and the
   subtitle on one optical grid however long the copy runs: the mark column is
   a fixed width, so every title in the stack starts on the same x. */
.pf-cv-radio{ display:grid; grid-template-columns:1.05rem 1fr; align-items:start;
  column-gap:.75rem; row-gap:.15rem; min-width:0;
  padding:1rem; border:1px solid var(--pf-border); border-radius:.6rem;
  cursor:pointer; background:var(--pf-surface, #fff);
  transition:border-color .15s ease, background .15s ease, box-shadow .15s ease, transform .06s ease; }
.pf-cv-radio > input[type=radio]{ margin-top:.15rem; cursor:pointer; }
.pf-cv-radio:hover{ background:var(--pf-surface-soft); border-color:var(--pf-border-strong, #C9CCD1); }
/* The ring is on the CARD, not the radio: the card is what you clicked, so the
   card is what should show it has focus. `:focus-within` is what carries the
   input's focus out to its container. */
.pf-cv-radio:focus-within{ border-color:var(--pf-primary);
  box-shadow:0 0 0 3px var(--pf-primary-soft, #F3F1FE); outline:none; }
.pf-cv-radio:active{ transform:translateY(1px); }
.pf-cv-radio.is-on{ border-color:var(--pf-primary); background:var(--pf-primary-soft, #F3F1FE); }
.pf-cv-radio.is-on:hover{ background:var(--pf-primary-soft, #F3F1FE); }
/* A disabled card must not pretend to be pressable. */
.pf-cv-radio:has(input:disabled){ cursor:default; }
.pf-cv-radio:has(input:disabled):hover{ background:var(--pf-surface, #fff); border-color:var(--pf-border); }
.pf-cv-radio:has(input:disabled):active{ transform:none; }

/* The note that sits immediately above the submit button. Adjacent to the
   action it qualifies, because a warning read after you have clicked is not a
   warning. */
.pf-cv-note{ display:flex; align-items:flex-start; gap:.45rem; margin-top:.85rem;
  padding:.55rem .7rem; border-radius:.5rem;
  background:var(--pf-surface-soft); color:var(--pf-muted);
  font-size:.72rem; line-height:1.45; }
.pf-cv-note svg{ flex:0 0 auto; margin-top:.1rem; }

/* --- Live preview ---------------------------------------------------- */
/* One A4 viewport that SCROLLS through however many pages the CV needs; the
   document inside draws its own page edges. Keeping the viewport at A4
   proportions at every width matters — a capped height instead (an earlier
   rule) let the frame letterbox the sheet against its own backdrop on
   phones, which looked like a rendering fault rather than a page. */
.pf-cv-preview{ position:relative; width:100%; aspect-ratio:1 / 1.414;
  border:1px solid var(--pf-border); border-radius:.5rem;
  overflow-y:auto; overflow-x:hidden; background:#EEF0F4;
  -webkit-overflow-scrolling:touch; }
/* The preview is the CV document at its real A4 width (794px at 96dpi),
   scaled down by JS to whatever the panel is. Scaling rather than reflowing
   is the point: a reflowed preview would be a different layout from the PDF,
   which is the exact drift this architecture exists to prevent. */
.pf-cv-scaler{ position:relative; width:100%; }
.pf-cv-scaler > iframe{
  width:794px; height:1123px; border:0; display:block; background:#fff;
  transform-origin:top left; }

/* Swapping the document is not instant, and a frame that goes white with no
   explanation reads as a crash. Veil it while the new one is fetched. */
.pf-cv-veil{ position:absolute; inset:0; z-index:2; display:grid; place-items:center;
  background:rgba(255,255,255,.74); opacity:0; visibility:hidden;
  transition:opacity .12s ease, visibility .12s ease; }
.pf-cv-preview.is-loading .pf-cv-veil{ opacity:1; visibility:visible; }
.pf-cv-spin{ display:block; width:1.75rem; height:1.75rem; border-radius:50%;
  border:2px solid var(--pf-border-strong); border-top-color:var(--pf-primary);
  animation:pf-spin .7s linear infinite; }

/* PREVIEW STATE — "you are looking at something that is not your CV".
   A quiet outline pill rather than a filled one: the loud element in this
   panel is the document, and a badge that shouts would be the third thing
   competing with it after the step number and the page count. */
.pf-badge-preview{ background:var(--pf-primary-soft-2, #F2F0FD);
  color:var(--pf-primary-ink); border-color:var(--pf-primary-border, #D9D2FB);
  font-weight:700; letter-spacing:.02em; }
.pf-badge-preview.is-hidden{ display:none; }

/* THE UPSELL PANEL. It occupies the place the download actions occupy, at the
   same width, so the panel does not reflow when a preview opens or closes —
   the actions change, the layout does not. */
.pf-cv-upsell{ padding:.85rem; border-radius:.6rem;
  border:1px solid var(--pf-primary-border, #D9D2FB);
  background:var(--pf-primary-soft-2, #F2F0FD); }
.pf-cv-upsell-title{ display:flex; align-items:center; gap:.4rem;
  font-size:.85rem; font-weight:700; color:var(--pf-primary-ink); }
.pf-cv-upsell-title svg{ flex:0 0 auto; }
.pf-cv-upsell-text{ margin-top:.3rem; font-size:.75rem; line-height:1.5;
  color:var(--pf-muted); }
.pf-cv-upsell .pf-cv-actions{ margin-top:.75rem; }

/* "This is not your data" — said under the sheet, where somebody looking at a
   design they do not recognise will actually look for the explanation. */
.pf-cv-samplenote{ display:flex; align-items:flex-start; gap:.4rem;
  margin-top:.5rem; font-size:.7rem; line-height:1.5; color:var(--pf-muted); }
.pf-cv-samplenote svg{ flex:0 0 auto; margin-top:.15rem; }

/* The one line under the preview that says what the PDF is doing: nothing,
   preparing, or ready. It is `aria-live`, so it is also what a screen reader
   hears when a download starts. */
/* Position only. The STATE — working / done / failed, and the dot that shows
   it — is the shared `.pf-op` operation line, so this panel no longer keeps a
   private copy of a pattern the whole product needs. */
.pf-cv-state{ min-height:1.1rem; margin-top:.45rem; font-size:.7rem; color:var(--pf-muted);
  display:flex; align-items:center; gap:.35rem; }

.pf-cv-actions{ display:grid; gap:.45rem; margin-top:.75rem; }

/* The page-count pill. `.pf-badge`'s own default already reads as quiet; this
   exists so the markup names what it means rather than reaching for a
   Tailwind colour pair that a purge could drop. */
.pf-badge-quiet{ background:var(--pf-surface-soft); color:var(--pf-muted);
  border-color:var(--pf-border); }

/* --- Sticky phone action bar ----------------------------------------- */
/* Phones only, and only on the builder. The screen exists to end in a
   download, and on a phone that button is otherwise two screenfuls below
   the controls somebody is still adjusting. */
.pf-cv-bar{ position:sticky; bottom:0; z-index:20; margin:1rem -1rem -1rem;
  padding:.6rem 1rem calc(.6rem + env(safe-area-inset-bottom));
  background:var(--pf-surface); border-top:1px solid var(--pf-border);
  box-shadow:0 -4px 16px rgba(17,19,24,.06); }

/* --- Touch sizing ----------------------------------------------------- */
/* Coarse pointers only. Growing these for a mouse would make a dense panel
   loose for no benefit; not growing them for a thumb leaves the move buttons
   a 16px target, which is a miss waiting to happen. */
@media (pointer:coarse){
  .pf-sec{ padding:.55rem .6rem; }
  /* On a phone the select is the one control that has to be tappable rather
     than merely visible, so it takes a real target and a permanent border. */
  .pf-sec-place-sel{ padding:.4rem 1.25rem .4rem .5rem; border-color:var(--pf-border); }
  .pf-sec-move{ width:2rem; height:1.5rem; }
  .pf-sec .pf-switch{ padding:.35rem 0; }
  .pf-seg-opt{ padding:.5rem .8rem; }
  .pf-swatch{ padding:.4rem .7rem .4rem .4rem; }
  .pf-acc-head{ padding:.75rem .7rem; }
  .pf-check{ padding:.15rem 0; }
}

/* --- Reduced motion --------------------------------------------------- */
@media (prefers-reduced-motion:reduce){
  .pf-tpl, .pf-acc-chev, .pf-cv-veil, .pf-sec, .pf-seg-opt, .pf-swatch{ transition:none; }
  .pf-tpl:hover{ transform:none; }
  .pf-cv-spin{ animation:none; border-top-color:var(--pf-border-strong); }
}

/* =====================================================================
   ADMIN → CV panel
   ---------------------------------------------------------------------
   One table on a laptop, one card per template on a phone — the SAME
   markup, re-flowed. A template row carries eight editable or reported
   values (name, slug, blurb, plan, order, active, version, usage), which
   is a table on a desktop and unreadable as one on a phone.
   ===================================================================== */
.pf-admincv-wrap{ overflow-x:auto; }
.pf-admincv{ width:100%; border-collapse:collapse; font-size:.8rem; }
.pf-admincv th{ text-align:left; font-size:.68rem; font-weight:700; letter-spacing:.05em;
  text-transform:uppercase; color:var(--pf-muted); padding:.4rem .55rem;
  border-bottom:1px solid var(--pf-border); white-space:nowrap; }
.pf-admincv td{ padding:.5rem .55rem; border-bottom:1px solid var(--pf-border);
  vertical-align:middle; }
.pf-admincv tr.is-off{ opacity:.62; }
.pf-admincv .pf-input{ font-size:.78rem; padding:.3rem .45rem; min-height:2rem; }
.pf-admincv-name{ font-weight:600; color:var(--pf-text); }
.pf-admincv-slug{ font-family:ui-monospace,Menlo,monospace; font-size:.65rem; color:var(--pf-muted); }
.pf-admincv-w-order{ width:5.5rem; }
.pf-admincv-w-plan { width:8rem; }
.pf-admincv-cell{ display:flex; align-items:center; gap:.5rem; min-width:0; }

/* --- Template row: read-only summary + an Edit drawer -------------------
   The copy fields (name, description, audience) used to be three <input>s in a
   table cell, which made every row four controls tall and the table impossible
   to scan. They now live in a drawer and the cell shows what they SAY.
   The inputs are still inside the same <form> — moved, not removed — so the
   POST contract (`name[slug]`, `description[slug]`, `audience[slug]`) is
   untouched and a row that is never opened still submits its current values. */
.pf-admincv-summary{ display:block; min-width:0; }
.pf-admincv-desc{ display:block; color:var(--pf-muted); font-size:.72rem; line-height:1.35;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:22rem; }
.pf-admincv-desc.is-empty{ font-style:italic; opacity:.7; }
.pf-admincv-tag{ display:inline-block; margin-left:.35rem; padding:.05rem .3rem;
  border:1px solid var(--pf-border); border-radius:.25rem;
  font-family:ui-monospace,Menlo,monospace; font-size:.6rem; color:var(--pf-muted); }

/* Icon-only action buttons. A 2rem target is the smallest thing that is still
   comfortably clickable with a mouse and tappable on a laptop trackpad; the
   accessible name comes from the sr-only span inside, so the button is not a
   mystery to a screen reader just because it has no visible text. */
.pf-admincv-acts{ display:flex; align-items:center; gap:.25rem; justify-content:flex-end; }
.pf-iconbtn{ display:inline-grid; place-items:center; width:2rem; height:2rem; padding:0;
  border:1px solid var(--pf-border); border-radius:.45rem; background:var(--pf-surface, #fff);
  color:var(--pf-muted); cursor:pointer; line-height:0;
  transition:background .15s ease, color .15s ease, border-color .15s ease; }
.pf-iconbtn:hover{ background:var(--pf-surface-soft); color:var(--pf-text); border-color:var(--pf-border-strong, #C9CCD1); }
.pf-iconbtn:focus-visible{ outline:2px solid var(--pf-primary); outline-offset:1px; }
.pf-iconbtn:active{ transform:translateY(1px); }
.pf-iconbtn-danger:hover{ color:var(--pf-danger, #B42318); border-color:var(--pf-danger, #B42318); }

/* A MODAL BUILT FROM <details>, so it needs no JavaScript at all.
   When open, the <summary> becomes the full-screen backdrop — clicking it (or
   pressing Enter on it, since it keeps focus) closes the drawer. That is the
   whole close mechanism, and it is why this degrades perfectly: the same markup
   is a working disclosure with scripting off. */
.pf-tpledit{ display:inline-block; }
.pf-tpledit > summary{ list-style:none; display:inline-grid; place-items:center;
  width:2rem; height:2rem; border:1px solid var(--pf-border); border-radius:.45rem;
  background:var(--pf-surface, #fff); color:var(--pf-muted); cursor:pointer; line-height:0;
  transition:background .15s ease, color .15s ease, border-color .15s ease; }
.pf-tpledit > summary::-webkit-details-marker{ display:none; }
.pf-tpledit > summary:hover{ background:var(--pf-surface-soft); color:var(--pf-text); }
.pf-tpledit > summary:focus-visible{ outline:2px solid var(--pf-primary); outline-offset:1px; }
.pf-tpledit[open] > summary{ position:fixed; inset:0; width:auto; height:auto;
  border:0; border-radius:0; background:rgba(17,19,24,.45); z-index:60; }
.pf-tpledit[open] > summary > *{ display:none; }
.pf-tpledit-box{ position:fixed; z-index:61; left:50%; top:50%; transform:translate(-50%,-50%);
  width:min(34rem, calc(100vw - 2rem)); max-height:calc(100vh - 3rem); overflow:auto;
  background:var(--pf-surface, #fff); border-radius:.8rem; padding:1.15rem;
  box-shadow:0 12px 40px rgba(17,19,24,.22); text-align:left; }
.pf-tpledit-title{ font-size:.95rem; font-weight:700; color:var(--pf-text); }
.pf-tpledit-sub{ font-family:ui-monospace,Menlo,monospace; font-size:.68rem; color:var(--pf-muted); }
.pf-tpledit-field{ display:block; margin-top:.85rem; }
.pf-tpledit-field > span{ display:block; font-size:.72rem; font-weight:600;
  color:var(--pf-text); margin-bottom:.25rem; }
.pf-tpledit-hint{ display:block; font-size:.68rem; color:var(--pf-muted); margin-top:.25rem; }
.pf-tpledit-foot{ margin-top:1.1rem; padding-top:.75rem; border-top:1px solid var(--pf-border);
  font-size:.7rem; color:var(--pf-muted); }
@media (prefers-reduced-motion: reduce){
  .pf-cv-radio, .pf-iconbtn{ transition:none; }
  .pf-cv-radio:active, .pf-iconbtn:active{ transform:none; }
}


/* Below 60rem the table becomes a stack of cards. `display:block` on the
   parts is what re-flows it; the header row is hidden because a card
   labels its own fields through `data-label`. */
@media (max-width:60rem){
  .pf-admincv, .pf-admincv tbody, .pf-admincv tr, .pf-admincv td{ display:block; width:100%; }
  .pf-admincv thead{ display:none; }
  .pf-admincv tr{ border:1px solid var(--pf-border); border-radius:.6rem;
    padding:.65rem; margin-bottom:.6rem; }
  .pf-admincv td{ border:0; padding:.3rem 0; }
  .pf-admincv td[data-label]::before{ content:attr(data-label); display:block;
    font-size:.65rem; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
    color:var(--pf-muted); margin-bottom:.2rem; }
  .pf-admincv-w-order, .pf-admincv-w-plan{ width:auto; }
}

/* Compact stat strip — the admin CV panel's headline numbers. Deliberately
   not .pf-kpi: those are tall dashboard cards, and a row of them above a
   table pushed the table itself below the fold on a laptop. */
/* The shared rank bar is a fixed 3.5rem chip, which is right beside a dense
   analytics list and far too small as the ONLY chart on a half-width card.
   A modifier rather than a change to the component: every other page that uses
   it wants the small one. */
.pf-rank-bar-wide{ flex:1 1 auto; width:auto; min-width:3.5rem; }

.pf-stat-strip{ display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:.5rem; }
@media (min-width:48rem){ .pf-stat-strip{ grid-template-columns:repeat(4, minmax(0,1fr)); } }
.pf-stat{ border:1px solid var(--pf-border); border-radius:.5rem; padding:.5rem .65rem;
  background:var(--pf-surface); min-width:0; }
.pf-stat-label{ display:block; font-size:.64rem; font-weight:700; letter-spacing:.04em;
  text-transform:uppercase; color:var(--pf-muted); }
.pf-stat-value{ display:block; font-size:1.05rem; font-weight:700; color:var(--pf-text);
  line-height:1.3; margin-top:.1rem; }
.pf-stat-sub{ display:block; font-size:.65rem; color:var(--pf-muted); }
.pf-stat.is-alert .pf-stat-value{ color:var(--pf-danger); }

/* The admin CV panel's section tabs used to declare a SECOND, underline-styled
   `.pf-tabs`/`.pf-tab` component here — unscoped, and 3200 lines after the
   real one, so it silently re-styled every tab bar in the product: a stray
   hairline under each pill track, square bottom corners on the pills, a 1rem
   margin nothing asked for, and a grey :hover fill that beat `.pf-tab.active`
   on source order. It is deleted; that page now uses the shared pill
   component with `.active` like everything else. Do not reintroduce a second
   tabs component — see `.pf-tabs` / `.pf-tabs-icons` above. */

/* ---------------------------------------------------------------------
   MODAL SURFACE ISOLATION — must stay LAST in this file.

   Dark public themes restyle .pf-btn-ghost for a dark page
   (light text on a translucent white fill). A modal card is always a
   LIGHT surface (--pf-surface), so inside it those overrides produce
   near-white text on white — the Cancel button of any dialog opened from
   a dark profile page was effectively invisible.

   The card is theme-independent by definition, so the fix is a boundary,
   not a per-theme patch: restore the base treatment for anything inside
   .pf-modal-card. Same specificity as `.theme-x .pf-btn-ghost` (0,2,0),
   so it wins purely by being later — which is why this block must remain
   at the end of the file, after every theme.
   --------------------------------------------------------------------- */
.pf-modal-card .pf-btn-ghost{
  background:var(--pf-surface-soft); color:var(--pf-text); border-color:var(--pf-border-strong); }
.pf-modal-card .pf-btn-ghost:hover{
  background:var(--pf-surface-strong); color:var(--pf-text); border-color:#C2C7D0; }
.pf-modal-card .pf-btn-subtle{ color:var(--pf-muted); background:transparent; }
/* A dialog is the PLATFORM speaking, not the profile — so it takes the chrome
   font and ink even when it opens on a serif/mono theme. This does not violate
   the "themes keep their typography" rule above: that rule protects the
   profile's own content, and a modal is not the profile's content. A direct
   declaration also beats inheritance, so no theme selector has to be listed. */
.pf-modal-card{ color:var(--pf-text); font-family:var(--pf-font); }

/* ---------------------------------------------------------------------------
   Country flag — ONE component for every country shown in the product
   (admin Visitors, Login Activity, Stats, dashboard Analytics and Security).

   The chip is a fixed 4:3 box with the artwork `contain`ed inside it. Flags are
   not all 4:3 — Switzerland and the Vatican are square, Nepal is not even a
   rectangle — so `contain` is what keeps every one of them undistorted inside a
   column that has to stay a single width. A missing file, an unknown country
   and a NULL code all render .pf-flag-blank at exactly the same size, so a
   table never shifts depending on which flags happen to be installed.
--------------------------------------------------------------------------- */
.pf-flag{
  display:inline-block; flex:0 0 auto;
  width:1.25rem; height:.9375rem;            /* 20 × 15 — 4:3 */
  border-radius:2px; overflow:hidden;
  background:var(--pf-surface-soft);
  box-shadow:inset 0 0 0 1px rgb(0 0 0 / .08);
}
.pf-flag img{ display:block; width:100%; height:100%; object-fit:contain; }
.pf-flag-blank{ background:var(--pf-surface-soft); }

/* Flag + name, locked to one line. inline-flex keeps the pair a single inline
   item, so it sits inside a sentence ("City · Country") without the flag
   becoming its own flex item and stacking on a narrow screen. */
.pf-country{
  display:inline-flex; align-items:center; gap:.375rem;
  min-width:0; max-width:100%; vertical-align:-.15em;
}
.pf-country-name{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }

/* ---------------------------------------------------------------------------
   Searchable select (.pf-combo) — ONE component for every long option list
   (profile country, Afghan city, admin location filter).

   Rendered by combo_select() as a real <select> and upgraded by location.js.
   Both states are styled here: with the script off you get `select.pf-input`
   exactly as before, so nothing below is load-bearing for the form working.

   The button deliberately reuses .pf-input for its box, so an upgraded field
   and the text input beside it are the same object at the same height —
   the country and city fields sit in one grid row and must not disagree by a
   pixel.
--------------------------------------------------------------------------- */
.pf-combo{ position:relative; }

/* ---- SmartCombobox (.pf-smart) --------------------------------------------
   The taxonomy-backed sibling of the country picker. It shares every part of
   the popover below — rows, tick, empty state, sheet, drag — and differs in
   exactly two places, both here:

     1. its trigger is a REAL text input, not a button, because the field is
        also the search box on desktop (no extra click to open a picker on a
        field somebody fills in twenty times while listing their skills);
     2. the popover's own search band is therefore redundant on desktop and is
        hidden there. On the phone sheet it is the only search box, because
        typing into the field would put the keyboard over the results. */
.pf-smart-field{ position:relative; }
.pf-smart-input{ padding-right:2.1rem; }
.pf-smart-toggle{ position:absolute; right:.35rem; top:50%; transform:translateY(-50%);
  display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:8px;
  color:var(--pf-muted); background:transparent; border:0; cursor:pointer; }
.pf-smart-toggle:hover{ color:var(--pf-text); background:var(--pf-surface-strong); }

/* A custom value is visually a DIFFERENT KIND of row from a catalogue match —
   it is an action ("use what I typed"), not a suggestion. The separator says
   so without a second colour or an icon nobody has seen before. */
.pf-combo-opt-custom{ border-top:1px solid var(--pf-border); margin-top:.25rem; padding-top:.6rem; }
.pf-combo-opt-custom .pf-combo-opt-label{ color:var(--pf-primary-ink); font-weight:600; }

@media (min-width:640px){
  .pf-smart .pf-combo-head,
  .pf-smart .pf-combo-grab{ display:none; }
  /* The field is the anchor, so the panel hangs from the field's own box
     rather than from the wrapper, which also holds the popover. */
  .pf-smart .pf-combo-pop{ top:calc(100% + .35rem); }
}

.pf-combo-btn{ display:flex; align-items:center; gap:.5rem;
  text-align:left; cursor:pointer; padding-right:2.1rem; }
.pf-combo-btn:disabled{ cursor:not-allowed; }
.pf-combo-val{ display:flex; align-items:center; gap:.5rem; flex:1; min-width:0; }
/* Long country names (…South Georgia and the South Sandwich Islands) truncate
   inside the button rather than widening the grid column that holds it. */
.pf-combo-text{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pf-combo-note{ font-size:.75rem; color:var(--pf-muted); flex:0 0 auto; }
.pf-combo-ph{ color:var(--pf-muted); }
.pf-combo-caret{ position:absolute; right:.6rem; top:50%; transform:translateY(-50%);
  display:flex; color:var(--pf-muted); pointer-events:none; }
/* While the panel is open the panel carries the focus state, and the trigger
   goes quiet. Both of them wearing a brand border and a ring was the "thick
   purple double outline" — two stacked highlights for one action. */
.pf-combo.is-open .pf-combo-btn{ border-color:var(--pf-border-strong); box-shadow:none; }

/* Same surface language as .pf-menu — one floating panel in this product, and
   the ONLY border and radius in the component. */
.pf-combo-pop{ position:absolute; z-index:120; top:calc(100% + .35rem); left:0; right:0;
  display:flex; flex-direction:column;
  background:var(--pf-surface); border:1px solid var(--pf-border);
  border-radius:var(--pf-radius-lg); overflow:hidden;
  box-shadow:0 1px 2px rgba(17,19,24,.04), 0 16px 40px -16px rgba(17,19,24,.28);
  animation:pf-menu-in .16s cubic-bezier(.2,.7,.2,1); }
/* NO focus ring anywhere in this component — not on the input, not on the panel
   around it. The panel is already the only thing on screen: it has just been
   opened by a deliberate action, it floats above a scrim or a dimmed page, and
   the text caret is the standard indicator a text field is ready. A brand edge
   and a 3px ring on top of that was one signal too many. */

/* THE SEARCH BAND — part of the panel, not a control sitting on it.

   It used to be a bordered, rounded input nested inside the bordered, rounded
   popover, so focusing it drew a third outline inside the other two. There is
   now exactly one border in the whole component (the panel's), one radius
   (the panel's), and no focus outline at all. The band is only a row: an icon,
   the text, a close action, separated from the list by a single hairline.

   Fixed height (2.75rem, the product's touch-control height) so the icon,
   placeholder and ✕ share one optical centre line at every width. */
.pf-combo-head{ display:flex; align-items:center; gap:.625rem; flex:0 0 auto;
  min-height:2.75rem; padding:0 .5rem 0 .75rem;
  border-bottom:1px solid var(--pf-border); background:var(--pf-surface); }
.pf-combo-head-ico{ display:flex; align-items:center; color:var(--pf-muted); flex:0 0 auto; }
.pf-combo-search{ flex:1; min-width:0; align-self:stretch;
  border:0; outline:none; background:transparent; box-shadow:none;
  font-size:.875rem; color:var(--pf-text); padding:0; }
.pf-combo-search::placeholder{ color:var(--pf-muted); }

/* THE SEARCH FIELD LOOKS IDENTICAL FOCUSED AND UNFOCUSED.
   Two rules were painting it and both outranked the plain declarations above,
   which is why `outline:none` there did nothing:

     app.css      `input:focus-visible`            2px --pf-primary outline (purple)
     forms plugin `input:where([type=text]):focus` 1px #2563eb box-shadow ring (blue)
                                                   + a #2563eb border-color

   `:where()` contributes no specificity, so both are (0,1,1) while
   `.pf-combo-search` alone is (0,1,0). Adding the field's own pseudo-class
   makes this (0,2,0) — it wins on specificity rather than on source order, so
   it holds wherever the two stylesheets end up. The --tw-ring-* resets are
   belt-and-braces in case a utility recomposes box-shadow from them.

   Deliberate exception, not a pattern: this is a text box inside a panel the
   user just opened on purpose, above a scrim, with a caret in it. Every other
   focusable thing in the product — including the ✕ beside this field — keeps
   the global ring. */
.pf-combo-search:focus,
.pf-combo-search:focus-visible{
  outline:none; outline-offset:0;
  box-shadow:none;
  /* currentColor is what `border:0` above resolves to, so focused and resting
     match exactly rather than merely both being invisible at 0px — and the
     field stays correct if it is ever given a visible border. */
  border-color:currentColor;
  --tw-ring-shadow:0 0 #0000;
  --tw-ring-offset-shadow:0 0 #0000;
}
/* Square, centred on the same line as the icon opposite it. */
.pf-combo-close{ display:flex; align-items:center; justify-content:center;
  width:1.875rem; height:1.875rem; flex:0 0 auto;
  border-radius:8px; color:var(--pf-muted);
  transition:background-color var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease); }
.pf-combo-close:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }

/* The phone sheet's drag affordance. Inert everywhere else. */
.pf-combo-grab{ display:none; }

.pf-combo-list{ list-style:none; margin:0; padding:.3rem; max-height:17rem;
  overflow-y:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch; }
.pf-combo-opt{ display:flex; align-items:center; gap:.55rem;
  padding:.5rem .55rem; border-radius:9px; font-size:.875rem; color:var(--pf-text);
  cursor:pointer; }
.pf-combo-opt.is-active{ background:var(--pf-surface-strong); }
.pf-combo-opt.is-on{ font-weight:600; }
.pf-combo-opt-label{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pf-combo-opt-note{ font-size:.72rem; color:var(--pf-muted); flex:0 0 auto; }
/* Reserved whether or not this row is the chosen one, so the labels of a
   scrolling list never shift sideways as the tick moves. */
.pf-combo-opt-tick{ width:1rem; height:1rem; flex:0 0 auto; display:flex;
  align-items:center; justify-content:center; color:var(--pf-primary); }
.pf-combo-opt-clear{ color:var(--pf-muted); }
.pf-combo-empty{ padding:1.75rem .625rem; text-align:center; font-size:.8125rem;
  color:var(--pf-muted); }

/* ---- Icon picker (.pf-iconpick) -------------------------------------------
   The third control in the .pf-combo family — same trigger box, same panel,
   same sheet, same search band. Everything below is only the difference: a
   GRID of pictures where the others have rows of text.

   The icons are drawn in the UI foreground colour, never in brand colour.
   Sixty-five coloured glyphs is not a picker, it is a chart; and an icon that
   is purple here but ink-black on the profile is showing the user something
   that is not true. Selection is the ONLY colour in the grid.
--------------------------------------------------------------------------- */
.pf-iconpick .pf-iconpick-btn{ padding-right:2.1rem; }
/* Room for the ✕ beside the caret — reserved only while there IS something to
   clear, so an empty field is not permanently indented for a button nobody can
   see. */
.pf-iconpick.is-set .pf-iconpick-btn{ padding-right:3.9rem; }

/* The chosen icon, at the size the profile draws it. Empty and unpainted when
   nothing is chosen: an empty field shows its placeholder text and no box. */
.pf-iconpick-chip{ display:flex; align-items:center; justify-content:center;
  flex:0 0 auto; color:var(--pf-text); }
.pf-iconpick-chip:empty{ display:none; }

.pf-iconpick-clear{ position:absolute; right:2rem; top:50%; transform:translateY(-50%);
  display:flex; align-items:center; justify-content:center;
  width:1.625rem; height:1.625rem; border-radius:8px;
  color:var(--pf-muted); background:transparent; border:0; cursor:pointer;
  transition:background-color var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease); }
.pf-iconpick-clear:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }

/* The grid. `auto-fill` rather than a fixed column count, so the same rule
   serves a 320px sheet and a 380px dashboard column without a breakpoint —
   the cells stay the same size and the number per row follows the width. */
.pf-icon-grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(2.75rem, 1fr));
  gap:.125rem; padding:.5rem; max-height:16.5rem; align-content:start; }
.pf-icon-cell{ display:flex; align-items:center; justify-content:center;
  aspect-ratio:1; border-radius:10px; color:var(--pf-text); cursor:pointer;
  -webkit-tap-highlight-color:transparent;
  transition:background-color var(--pf-dur-1) var(--pf-ease); }
.pf-icon-cell:hover,
.pf-icon-cell.is-active{ background:var(--pf-surface-strong); }
/* THE ONE SELECTED STATE — the product's own accent treatment, the same tint
   and border a chosen chip wears elsewhere. No tick: a tick over a 20px glyph
   obscures the very thing the cell exists to show. */
.pf-icon-cell.is-on{ background:var(--pf-primary-soft); color:var(--pf-primary-ink);
  box-shadow:inset 0 0 0 1px var(--pf-primary-border); }
.pf-icon-cell.is-on:hover,
.pf-icon-cell.is-on.is-active{ background:var(--pf-primary-soft-2); }
.pf-icon-cell svg{ width:1.25rem; height:1.25rem; }

/* A quiet band, not a section header: it labels the twelve below it and then
   gets out of the way. Full-width inside the grid. */
.pf-icon-head{ grid-column:1/-1; font-size:.6875rem; font-weight:600;
  letter-spacing:.04em; text-transform:uppercase; color:var(--pf-muted);
  padding:.5rem .25rem .25rem; }
.pf-icon-head:first-child{ padding-top:.125rem; }

/* Clearing from inside the panel — one row, at the bottom, present only while
   there is a value. It is the last thing in the panel on purpose: removing an
   icon is rarer than choosing one and must not sit above the grid taking the
   place of a result. */
.pf-iconpick-foot{ flex:0 0 auto; border-top:1px solid var(--pf-border);
  padding:.375rem; background:var(--pf-surface); }
.pf-iconpick-remove{ display:flex; align-items:center; gap:.5rem; width:100%;
  padding:.5rem .55rem; border-radius:9px; border:0; background:transparent;
  font-size:.8125rem; color:var(--pf-muted); cursor:pointer; text-align:left; }
.pf-iconpick-remove:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }

@media (max-width:639.98px){
  /* Comfortable thumb targets in the sheet — 3.25rem cells, which is 52px of
     tappable square rather than the 44px floor, because a grid of them is
     aimed at rather than read down. */
  .pf-icon-grid{ grid-template-columns:repeat(auto-fill, minmax(3.25rem, 1fr));
    gap:.25rem; padding:.5rem .625rem 1rem; max-height:none; }
  .pf-icon-cell svg{ width:1.5rem; height:1.5rem; }
  .pf-iconpick-foot{ padding:.375rem .625rem; }
  .pf-iconpick-remove{ min-height:2.75rem; }
  /* The trigger's ✕ reaches the 44px minimum without growing visually — the
     same technique .pf-combo-close uses. */
  .pf-iconpick-clear{ position:absolute; }
  .pf-iconpick-clear::before{ content:""; position:absolute; top:50%; left:50%;
    transform:translate(-50%,-50%); width:44px; height:44px; border-radius:inherit; }
}
@media (min-width:640px){
  /* The panel is as wide as the field it hangs from, which in the dashboard
     form column is ~360px — six or seven cells to a row. The floor stops it
     collapsing to two columns if the component is ever put in a narrow slot. */
  .pf-iconpick-pop{ min-width:min(19rem, calc(100vw - 2rem)); }
}

/* Inert at every width but the phone sheet below. */
.pf-combo-scrim{ display:none; }

/* ---- THE SHEET'S ESCAPE FROM ITS OWN ANCESTORS --------------------------
   Set by location.js on every ancestor of an OPEN phone sheet that creates a
   stacking context, and removed the moment it closes.

   Why it has to exist: a z-index only ranks an element against its siblings
   inside the nearest stacking context, and `position:sticky` creates one AT
   ANY Z-INDEX — unlike `relative` and `absolute`, which need one. The skills
   editor's form sits in a `.pf-card.sticky`, so the sheet's z-131 was being
   ranked inside a card that itself ranks at `auto` in the page, below this
   header's z-30. The header painted over the sheet and the scrim could not
   dim it, and NO value on the sheet itself could have fixed that — z-9999 on
   the sheet changed nothing when it was measured.

   140 is chosen against the product's whole scale, which this is the top of:
     20   sticky tabs          100  mobile nav shell / nav sheet
     30   dashboard header     130  combobox scrim, modal
     40   nav rail             131  combobox sheet
     120  desktop dropdown     135  admin detail panel
   The lifted element is never the scrim or the sheet — it is a container that
   HOLDS them, so 130/131 still order the two against each other inside it.

   `!important` because the lifted element is arbitrary page markup and may
   already carry a utility z-index; this has to win for the moments it is on,
   and it is on for no longer than a sheet is open. */
.pf-sheet-lift{ z-index:140 !important; }

/* Phone: ONE bottom sheet, opened at almost the full screen and draggable shut.

   THE SHEET IS ANCHORED BY ITS TOP EDGE, NOT BY A HEIGHT.

   It used to be `bottom:--pf-kb; height:--pf-sheet-h`, where the height was the
   visible viewport less the peek. Both of those values change the instant a
   keyboard opens, and only `bottom` was transitioned, so for the length of that
   transition the two edges described different sheets and the header slid up
   and came back. That was the whole of "the sheet jumps when the keyboard
   appears", "it flickers", "the header disappears": nothing was resizing the
   sheet, two properties were simply arriving at different times.

   Now the top is a constant and only the bottom follows the keyboard. The grab
   bar, the search band and the ✕ cannot move, because the box they are laid out
   from does not move. The list is the flex item that gives up the space, and it
   is already the one scrolling region — which is also why the search band stays
   put while only results scroll.

   TWO CUSTOM PROPERTIES, SET BY location.js FROM visualViewport:
     --pf-sheet-top  where the top edge sits, in px from the top of the screen.
                     A constant (the 44px of backdrop that says "this is not a
                     page") except on a screen too short to hold a usable sheet
                     under a tall keyboard, which is the only case where it moves
     --pf-kb         how far the visible bottom edge sits above the layout
                     bottom — i.e. the keyboard's height, 0 when it is closed
   The fallbacks are what the first painted frame uses, before JS measures — and
   they are the correct values for a closed keyboard, so the opening frame is
   right without waiting for a measurement. */
@media (max-width:639.98px){
  .pf-combo.is-open .pf-combo-scrim{ display:block; position:fixed; inset:0; z-index:130;
    background:rgba(17,19,24,.45); }

  .pf-combo-pop{
    position:fixed; z-index:131; left:0; right:0;
    top:var(--pf-sheet-top, 44px);
    bottom:var(--pf-kb, 0px);
    height:auto;
    max-height:none;
    border-radius:var(--pf-radius-lg) var(--pf-radius-lg) 0 0;
    padding-bottom:env(safe-area-inset-bottom);
    /* NO TRANSITION ON `bottom`. The visual viewport reports the keyboard's
       travel frame by frame, so the edge is already animated by the thing it is
       tracking; an easing curve on top of that can only lag behind the real
       keyboard and show a band of scrim under the sheet while it catches up.
       `transform` stays free for the drag. */
    /* Entrance is transform-only, so it cannot fight the edges above. While it
       runs the drag is inert — location.js clears it on the first pointerdown. */
    animation:pf-sheet-up .24s cubic-bezier(.32,.72,0,1);
    /* Docked to the bottom edge, so the shadow only has a top edge to cast. */
    box-shadow:0 -12px 40px -16px rgba(17,19,24,.35); }

  /* THE WHOLE TOP OF THE SHEET IS THE DRAG TARGET — the grab bar, the search
     band and the "Not specified" row under it. A 4px pill is a miserable thing
     to catch with a thumb, so the pill is only the affordance; the region is
     what you can actually grab.

     `touch-action:none` on it is what lets the pointer events win over the
     browser's own scrolling and text-selection.

     THE SEARCH FIELD AND THE ✕ ARE NO LONGER IN THAT LIST. They sit inside the
     region geometrically, but a gesture that starts on a control belongs to the
     control: location.js refuses to arm a drag from one, so suppressing their
     native touch behaviour bought nothing and cost the caret, the selection
     handles and scrolling a long value. The band around them still drags. */
  .pf-combo-grab{ display:flex; align-items:center; justify-content:center;
    flex:0 0 auto; height:1.5rem; cursor:grab;
    -webkit-tap-highlight-color:transparent; }
  .pf-combo-grab > span{ display:block; width:2.25rem; height:.25rem; border-radius:999px;
    background:var(--pf-border-strong); }
  .pf-combo.is-dragging .pf-combo-grab{ cursor:grabbing; }
  .pf-combo-grab,
  .pf-combo-head,
  .pf-combo-opt-clear{ touch-action:none; }
  .pf-combo-search,
  .pf-combo-close{ touch-action:auto; }

  /* WHEN THE LIST HAS NOTHING TO SCROLL, THE WHOLE SHEET DRAGS.
     A suggestion sheet is four rows on a panel most of the screen tall; there
     is no scrolling for the dismiss gesture to compete with down there, and
     making a thumb travel up to a 24px pill is precision the sheet has no
     reason to ask for. A country picker's 250 rows keep the rule above: the
     list is its own gesture, and only the band at the top dismisses.

     The list has to stop claiming the touch for a scroll it cannot perform,
     or the browser begins one and stops sending the pointer events the drag
     is made of. `touch-action` is read when the finger LANDS, so this cannot
     be decided in a pointerdown handler — location.js publishes the answer as
     `.is-drag-anywhere` and keeps it in step with the list's content.

     Taps are untouched: `touch-action` governs panning and zooming, never
     clicks, so every row is still exactly as tappable as it was. */
  .pf-combo.is-drag-anywhere .pf-combo-list{ touch-action:none; }

  .pf-combo-list{ max-height:none; flex:1 1 auto; padding:.3rem .5rem .6rem;
    /* The one scrolling region in the sheet. `min-height:0` is what makes that
       true: a flex item's automatic minimum size is its CONTENT, so without it
       a list of ten rows refuses to shrink, the column overflows the sheet's
       fixed box, and the part that gets pushed out of view is the search band
       at the top. The search stays; the results scroll. */
    min-height:0; overscroll-behavior:contain; }
  /* 44px, guaranteed rather than arithmetically hoped for: padding alone put
     a single-line row at 43px, and "one pixel under the minimum" is the kind
     of thing that is true on one font and false on another. */
  .pf-combo-opt{ padding:.7rem .55rem; min-height:2.75rem; }
  .pf-combo-head{ min-height:3rem; padding:0 .5rem 0 .875rem; }

  /* The ✕ is a 30px glyph and was a 30px TARGET, which is under the 44px
     minimum and is the other half of "✕ sometimes does not work" — half the
     time it was simply missed. Same technique as the gallery tile controls:
     a transparent ::before reaches out to 44×44 without changing how heavy
     the button looks. `position:relative` is what the reach is measured from. */
  .pf-combo-close{ position:relative; }
  .pf-combo-close::before{ content:""; position:absolute; top:50%; left:50%;
    transform:translate(-50%,-50%); width:44px; height:44px; border-radius:inherit; }
}

/* The page behind a sheet must not scroll under the finger.

   `overflow:hidden` alone does not achieve that on iOS Safari — the page keeps
   scrolling behind the sheet and is left somewhere else when it closes, which
   is what "the page jumped" actually was. location.js takes <body> out of flow
   and restores the exact scroll position on release; this only has to hold the
   line for browsers where overflow IS enough, and to stop the frozen body
   showing a second scrollbar. Scoped to the phone layout, and only ever set
   while a sheet is genuinely open. */
@media (max-width:639.98px){
  html.pf-combo-open, html.pf-combo-open body{ overflow:hidden; overscroll-behavior:none; }
}

/* ---- Desktop: an anchored panel that fits the room it has ---------------
   `place()` in location.js measures the space above and below the field and
   writes --pf-pop-max; when there is more room above, .is-above flips the
   anchor. The panel then scrolls internally rather than running off the fold,
   which is what made a dropdown near the bottom of the window look as though
   it had moved somewhere unexpected. */
@media (min-width:640px){
  .pf-combo-pop{ max-height:var(--pf-pop-max, 22rem); }
  .pf-combo-pop.is-above{ top:auto; bottom:calc(100% + .35rem); }
  /* The list is the one scrolling region; the search band stays put. */
  .pf-combo-list{ max-height:none; flex:1 1 auto; min-height:0; }
}

/* Loading: a hairline at the top of the panel, not a spinner that replaces the
   results. The previous answers stay readable while the next ones arrive —
   swapping a list for a spinner on every keystroke is how a fast typist ends
   up watching an animation instead of reading. */
.pf-combo-pop::before{ content:""; position:absolute; inset:0 0 auto 0; height:2px;
  background:var(--pf-primary); transform:scaleX(0); transform-origin:0 50%;
  transition:transform .18s var(--pf-ease); pointer-events:none; z-index:1; }
.pf-combo.is-loading .pf-combo-pop::before{
  animation:pf-combo-load 1.1s ease-in-out infinite; }
@keyframes pf-combo-load{
  0%{ transform:scaleX(0); transform-origin:0 50%; }
  50%{ transform:scaleX(1); transform-origin:0 50%; }
  51%{ transform:scaleX(1); transform-origin:100% 50%; }
  100%{ transform:scaleX(0); transform-origin:100% 50%; }
}

@keyframes pf-sheet-up{ from{ transform:translateY(100%); } }
@media (prefers-reduced-motion:reduce){
  .pf-combo-pop{ animation:none; transition:none; }
}

/* A .pf-rank-row that is also a link (admin → User locations). The rank row
   itself stays exactly as it is everywhere else; this only adds the affordance
   and the selected state, so a clickable list and a static one still read as
   the same component. */
.pf-loc-row{ padding:.3rem .45rem; margin:0 -.45rem; border-radius:9px; text-decoration:none;
  transition:background-color var(--pf-dur-1) var(--pf-ease); }
.pf-loc-row:hover{ background:var(--pf-surface-strong); }
.pf-loc-row.is-on{ background:var(--pf-primary-soft); }
.pf-loc-row.is-on .pf-rank-label{ font-weight:600; }

/* =====================================================================
   REMOTE PANEL  (.pf-panel, opened by [data-remote-panel] in app.js)
   =====================================================================
   ONE component, two shapes, decided by the viewport:

     desktop   a CENTRED MODAL — capped width, fixed header, scrolling body
     phone     a near-full-screen BOTTOM SHEET, dragged down to dismiss

   It replaced a right-hand drawer. A drawer puts a record you read
   top-to-bottom into the narrowest column on the widest screen; a centred
   modal gives that column the width it needs, and the table behind it stays
   exactly where the reader left it.

   The phone shape and its gesture are deliberately the same contract as the
   country picker (.pf-combo-pop): same height rule, same grab bar, same
   dismiss thresholds. Two sheets in one product should feel like one sheet.

   THE TWO CUSTOM PROPERTIES, SET BY app.js FROM visualViewport:
     --pf-sheet-h  the sheet's height in px, measured against the VISIBLE
                   viewport (not 100vh, which on iOS includes the area behind
                   the keyboard and would push the last rows out of reach)
     --pf-kb       how far the visible bottom edge sits above the layout
                   bottom — i.e. the keyboard's height, 0 when it is closed
   The fallbacks are what the first painted frame uses, before JS measures. */
html.pf-panel-open, html.pf-panel-open body{ overflow:hidden; overscroll-behavior:none; }

.pf-panel{ position:fixed; inset:0; z-index:135; opacity:0; pointer-events:none;
  transition:opacity var(--pf-dur-2) var(--pf-ease); }
.pf-panel.open{ opacity:1; pointer-events:auto; }

/* 50% black: strong enough to isolate the panel, light enough that the row
   the reader came from is still recognisable underneath. */
.pf-panel-scrim{ position:absolute; inset:0; background:rgba(15,23,42,.5);
  backdrop-filter:blur(2px); cursor:pointer; }

/* ---- Desktop: centred modal ---- */
.pf-panel-card{ position:absolute; top:50%; left:50%;
  transform:translate(-50%, calc(-50% + 10px));
  /* 46rem, not 40. The widest thing this panel carries is the before/after
     table, which is three columns of values that must not wrap — at 40rem a
     changed URL or a plan name plus its predecessor broke across lines and the
     comparison stopped being scannable. 94vw keeps it inside a small laptop. */
  width:min(94vw, 46rem); max-height:min(86vh, 52rem);
  display:flex; flex-direction:column; overflow:hidden;
  background:var(--pf-surface); border:1px solid var(--pf-border);
  border-radius:var(--pf-radius-lg);
  box-shadow:0 24px 70px -20px rgba(16,24,40,.35);
  transition:transform var(--pf-dur-2) var(--pf-ease); }
.pf-panel.open .pf-panel-card{ transform:translate(-50%,-50%); }

/* The grab bar advertises a gesture that only exists on a phone. */
.pf-panel-grab{ display:none; }

/* The fragment is loaded into .pf-panel-body, so THIS is what has to be the
   flex column — not the card. It never scrolls itself; it lays out the
   fragment's fixed head and its scrolling content. Getting this wrong makes
   the header scroll away with the body, which is the one thing a fixed header
   is for. */
.pf-panel-body{ flex:1 1 auto; min-height:0; overflow:hidden;
  display:flex; flex-direction:column; }

/* ---- Phone: bottom sheet ---- */
@media (max-width:639.98px){
  .pf-panel-card{
    top:auto; left:0; right:0; bottom:var(--pf-kb, 0px);
    width:auto; max-width:none;
    /* Two declarations, and the second is the one that matters before JS has
       measured anything: `dvh` is the DYNAMIC viewport, so the very first
       painted frame is already the right height on a phone whose URL bar is
       expanded. `vh` stays as the fallback for anything without dvh. The
       measured `--pf-sheet-h` overrides both the moment app.js runs. */
    height:var(--pf-sheet-h, 88vh); max-height:none;
    height:var(--pf-sheet-h, calc(100dvh - 2.75rem));
    border:0; border-top:1px solid var(--pf-border);
    border-radius:var(--pf-radius-lg) var(--pf-radius-lg) 0 0;
    padding-bottom:env(safe-area-inset-bottom);
    /* Docked to the bottom edge, so the shadow only has a top edge to cast. */
    box-shadow:0 -12px 40px -16px rgba(16,24,40,.35);
    /* `bottom` follows the keyboard; `transform` is the drag. They must not
       animate on the same property or they fight. */
    transform:translateY(100%);
    transition:transform .26s cubic-bezier(.32,.72,0,1), bottom .22s cubic-bezier(.32,.72,0,1); }
  .pf-panel.open .pf-panel-card{ transform:translateY(0); }

  /* THE WHOLE TOP OF THE SHEET IS THE DRAG TARGET — the grab bar and the
     fragment's fixed header. A 4px pill is a miserable thing to catch with a
     thumb, so the pill is only the affordance; the region is what you can
     actually grab. `touch-action:none` is what lets the pointer events win
     over the browser's own scrolling — safe here because app.js does not
     claim the gesture until the finger has travelled, so a tap on the close
     button still closes. */
  .pf-panel-grab{ display:flex; align-items:center; justify-content:center;
    flex:0 0 auto; height:1.5rem; cursor:grab; touch-action:none;
    -webkit-tap-highlight-color:transparent; }
  .pf-panel-grab > span{ display:block; width:2.25rem; height:.25rem; border-radius:999px;
    background:var(--pf-border-strong); }
  /* The drag region excludes the two BUTTONS in the header. `touch-action:none`
     hands the whole area to the pointer handler, and while app.js does not
     claim the gesture until the finger travels, a Back or Close press that
     drifts two pixels used to start dragging the sheet instead of activating
     the control it was on. */
  .pf-panel-card .pf-panel-head{ touch-action:none; }
  .pf-panel-head .pf-panel-backbtn,
  .pf-panel-head .pf-panel-x{ touch-action:manipulation; }

  /* A sheet is edge-to-edge, so its header can afford less side padding and
     needs the safe area on the sides in landscape. */
  .pf-panel-head{ padding:.85rem 1rem .8rem;
    padding-left:max(1rem, env(safe-area-inset-left));
    padding-right:max(1rem, env(safe-area-inset-right)); }
  .pf-panel-content{ padding:1rem 1rem calc(1.5rem + env(safe-area-inset-bottom));
    padding-left:max(1rem, env(safe-area-inset-left));
    padding-right:max(1rem, env(safe-area-inset-right)); }
  /* Icon only under the thumb — "Back to Islamic Republic of Afghanistan" is
     not a label a 390px header has room for beside a title and a close. */
  .pf-panel-backbtn .pf-panel-backlab{ display:none; }
}

@media (prefers-reduced-motion:reduce){
  .pf-panel, .pf-panel-card{ transition:none; }
}

/* ---- What a panel FRAGMENT provides -------------------------------------
   Every fragment loaded into a panel is the same three parts: a header that
   stays put, a body that scrolls, and a close button. Defined here, once, so
   four different fragments cannot drift into four different headers.

   The header is a flex item of .pf-panel-card and the body is the flex item
   that grows — that is the whole mechanism by which the header stays fixed
   while only the body scrolls, on both shapes, without position:sticky. */
.pf-panel-head{ display:flex; align-items:flex-start; gap:.75rem; flex:0 0 auto;
  padding:1.15rem 1.25rem 1rem; border-bottom:1px solid var(--pf-border);
  background:var(--pf-surface); }
/* The head's own text column. Fragments that predate this partial put their
   own .min-w-0 wrapper here; both shapes have to shrink rather than push the
   close button off the card. */
.pf-panel-head > *{ min-width:0; }
.pf-panel-title{ font-size:1rem; font-weight:700; line-height:1.45; color:var(--pf-text);
  margin:0; overflow-wrap:anywhere; }

/* ---- Back: inserted into the fragment's header by app.js ----------------
   Present only when this panel was opened FROM another one, and it names that
   one ("Afghanistan") rather than saying "Back" into the void. Same 44px
   target as the close button, on the opposite side of the header, so the two
   navigation controls of a dialog are exactly where a dialog's two navigation
   controls always are. */
.pf-panel-backbtn{ flex:0 0 auto; display:inline-flex; align-items:center; gap:.3rem;
  align-self:flex-start; max-width:11rem; min-height:2.25rem; margin:-.15rem 0 0 -.4rem;
  padding:.3rem .6rem .3rem .45rem; border-radius:999px;
  background:var(--pf-surface-soft); border:1px solid var(--pf-border);
  color:var(--pf-muted); font-size:.75rem; font-weight:600; cursor:pointer;
  transition:background-color var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease); }
.pf-panel-backbtn:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }
.pf-panel-backbtn:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }
.pf-panel-backbtn > svg{ flex:0 0 auto; }
.pf-panel-backlab{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }

/* ---- The shared header's own parts (partials/panel_head.php) ---- */
.pf-panel-lead{ flex:0 0 auto; display:inline-grid; place-items:center;
  width:2rem; height:2rem; border-radius:9px; margin-top:.1rem;
  background:var(--pf-surface-soft); color:var(--pf-primary-ink); }
.pf-panel-lead .pf-flag{ width:1.5rem; }
.pf-panel-idt{ flex:1 1 auto; min-width:0; }
/* The eyebrow is what stops "Afghanistan" being ambiguous between the visitor
   report and the registered-user one. It is the first line on purpose. */
.pf-panel-eyebrow{ font-size:.65rem; font-weight:700; letter-spacing:.06em;
  text-transform:uppercase; color:var(--pf-muted-2); margin:0 0 .15rem; }
.pf-panel-title:focus{ outline:none; }
.pf-panel-sub{ font-size:.8125rem; color:var(--pf-muted); margin:.25rem 0 0;
  overflow-wrap:anywhere; }
/* 44px target: the icon is 20px, the button is not. */
.pf-panel-x{ flex:0 0 auto; display:inline-grid; place-items:center;
  min-width:2.75rem; min-height:2.75rem; margin:-.6rem -.6rem 0 0;
  border-radius:10px; color:var(--pf-muted); cursor:pointer;
  transition:background-color var(--pf-dur-1) var(--pf-ease), color var(--pf-dur-1) var(--pf-ease); }
.pf-panel-x:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }
.pf-panel-x:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }
/* The ONE scrolling region — the body half of the fragment, below the fixed
   head. `min-height:0` is load-bearing: without it a flex item refuses to
   shrink below its content and the whole card grows instead of scrolling. */
.pf-panel-content{ flex:1 1 auto; min-height:0; overflow-y:auto;
  overscroll-behavior:contain; padding:1.1rem 1.25rem 1.75rem;
  /* Nothing inside a panel may widen it. A 900px table, a long referrer or a
     device code is scrolled INSIDE its own wrapper (.pf-table-wrap below);
     the panel itself never grows a horizontal scrollbar. */
  overflow-x:hidden; }
/* A table or a chart inside the panel scrolls sideways on its own, and its
   scroll must not chain out to the panel body once it reaches the end —
   otherwise a horizontal swipe on a wide table pulls the sheet around. */
.pf-panel-content .pf-table-wrap,
.pf-panel-content .pf-chart{ overscroll-behavior:contain; }
/* A loading fragment must not collapse the card to the height of one line and
   then snap back open — the panel keeps a floor while the request is out. */
.pf-panel-body .pf-remote-loading{ display:block; min-height:12rem; flex:1 1 auto;
  color:var(--pf-muted); font-size:.85rem; }

/* =====================================================================
   ACTIVITY — the shared body of Audit Log, Admin Activity, User Activity
   and the Contact archive
   =====================================================================
   ONE dataset, TWO structures, swapped at 640px:

     .pf-activity-table   a real <table>, desktop and tablet
     .pf-activity-cards   a real <ul>,   phone

   Each is display:none at the other's width, which removes it from the
   accessibility tree as well as the layout — so a screen reader is handed one
   correct structure rather than a table whose rows have been turned into
   blocks and thereby stopped being rows. */
.pf-activity-cards{ display:none; }

.pf-activity-table-wrap{ overflow-x:auto; -webkit-overflow-scrolling:touch; }
/* `table-layout:fixed` is the point of this rule, not an optimisation.
   With auto layout the browser distributes width by content, and the six
   short columns (time, actor, category, severity, result, chevron) all claim
   more than they need — leaving the ACTION column, which holds the sentence
   the whole page exists to show, wrapping to five lines beside three columns
   of white space. Fixed layout gives every other column exactly the width its
   `w-*` class asks for and hands the remainder to the one column with no
   width set, which is Action. */
.pf-activity-table{ width:100%; min-width:48rem; font-size:.875rem;
  table-layout:fixed; border-collapse:separate; border-spacing:0; }

/* Column widths as PERCENTAGES, so the sentence column grows with the window
   instead of getting whatever the fixed short columns leave over. Declared on
   <col> rather than on the first <th> because that is what a colgroup is for,
   and it keeps the header markup free of layout. */
.pf-activity-table .pf-c-time  { width:9%;  }
.pf-activity-table .pf-c-actor { width:16%; }
.pf-activity-table .pf-c-action{ width:41%; }
.pf-activity-table .pf-c-target{ width:16%; }
.pf-activity-table .pf-c-state { width:13%; }
.pf-activity-table .pf-c-open  { width:5%;  }

/* The action code, its category chip and the batch note, on one quiet line
   under the sentence they annotate. */
.pf-audit-meta{ display:flex; flex-wrap:wrap; align-items:center; gap:.35rem;
  margin-top:.3rem; font-size:.6875rem; color:var(--pf-muted); min-width:0; }
.pf-audit-meta > .font-mono{ overflow-wrap:anywhere; }

/* Severity above result. Stacked rather than side by side because at this
   column width they would otherwise wrap into a ragged two-and-one. */
.pf-audit-state{ display:flex; flex-direction:column; align-items:flex-start; gap:.25rem; }
.pf-activity-table thead th{
  position:sticky; top:0; z-index:2;
  /* Opaque, because rows scroll UNDER it. A translucent header over moving
     text is the single most common sticky-header defect. */
  background:var(--pf-surface-soft);
  text-align:left; font-weight:600; font-size:.75rem; letter-spacing:.01em;
  color:var(--pf-muted); padding:.7rem 1rem; white-space:nowrap;
  border-bottom:1px solid var(--pf-border); }
.pf-activity-table tbody td{ padding:.75rem 1rem; vertical-align:top;
  border-bottom:1px solid var(--pf-border); }
.pf-activity-table tbody tr:last-child td{ border-bottom:0; }

/* A row opens its detail on click. It is NOT a button — the control is the
   chevron in the last cell, which is what the keyboard and a screen reader
   reach. This is only the mouse affordance. */
.pf-audit-row{ cursor:pointer;
  transition:background-color var(--pf-dur-1) var(--pf-ease); }
.pf-audit-row:hover{ background:var(--pf-surface-soft); }
.pf-audit-row:focus-within{ background:var(--pf-surface-soft); }
.pf-audit-summary{ font-weight:500; line-height:1.45; overflow-wrap:anywhere; }

/* The real control. Visible on focus at any time; on a pointer device it
   fades in with its row, because eight of them shouting at once is noise. */
.pf-audit-open{ display:inline-grid; place-items:center; width:2rem; height:2rem;
  border-radius:8px; color:var(--pf-muted); cursor:pointer; opacity:.35;
  transition:opacity var(--pf-dur-1) var(--pf-ease),
             background-color var(--pf-dur-1) var(--pf-ease); }
.pf-audit-row:hover .pf-audit-open{ opacity:1; }
.pf-audit-open:hover{ background:var(--pf-surface-strong); color:var(--pf-text); }
.pf-audit-open:focus-visible{ opacity:1; outline:2px solid var(--pf-primary-ink);
  outline-offset:2px; }

@media (max-width:639.98px){
  .pf-activity-table-wrap{ display:none; }
  .pf-activity-cards{ display:flex; flex-direction:column; gap:.6rem;
    list-style:none; margin:0; padding:0; }
}

/* --- one event, as a card ------------------------------------------------
   The order is the order somebody reads it in: who and when, then WHAT
   happened in full — never truncated, because the summary is the row — then
   what it was done to, then the state badges. */
.pf-acard{ border:1px solid var(--pf-border); border-radius:14px;
  background:var(--pf-surface); overflow:hidden; }
.pf-acard-btn{ display:block; width:100%; text-align:left; cursor:pointer;
  padding:.85rem .9rem; background:transparent; }
.pf-acard-btn:focus-visible{ outline:2px solid var(--pf-primary-ink);
  outline-offset:-2px; }
.pf-acard-top{ display:flex; align-items:baseline; gap:.5rem; margin-bottom:.35rem; }
.pf-acard-actor{ font-size:.75rem; font-weight:700; color:var(--pf-text);
  min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pf-acard-time{ font-size:.6875rem; color:var(--pf-muted); margin-left:auto;
  flex:0 0 auto; font-variant-numeric:tabular-nums; }
.pf-acard-summary{ display:block; font-size:.875rem; line-height:1.5;
  color:var(--pf-text); overflow-wrap:anywhere; }
.pf-acard-target{ display:block; font-size:.75rem; color:var(--pf-muted);
  margin-top:.3rem; overflow-wrap:anywhere; }
.pf-acard-tags{ display:flex; flex-wrap:wrap; gap:.3rem; margin-top:.55rem; }

/* The actor's own history: a second destination, so a second control — with a
   44px tap target and a divider, not a link buried in the summary. */
.pf-acard-actor-link{ display:flex; align-items:center; gap:.4rem; width:100%;
  min-height:2.75rem; padding:.5rem .9rem; cursor:pointer;
  font-size:.75rem; font-weight:600; color:var(--pf-primary-ink);
  border-top:1px solid var(--pf-border); background:var(--pf-surface-soft); }
.pf-acard-actor-link:focus-visible{ outline:2px solid var(--pf-primary-ink);
  outline-offset:-2px; }

/* Selecting a contact submission for permanent purge, on a phone. A full-width
   label with a 44px target, above the card's own button rather than inside it:
   a checkbox nested in a control that opens something else is how people
   destroy the wrong record. */
.pf-acard-select{ display:flex; align-items:center; gap:.6rem; min-height:2.75rem;
  padding:.5rem .9rem; font-size:.75rem; font-weight:600; color:var(--pf-muted);
  border-bottom:1px solid var(--pf-border); background:var(--pf-surface-soft);
  cursor:pointer; }
.pf-acard-select input{ width:1.1rem; height:1.1rem; flex:0 0 auto; }
.pf-acard:has(.pf-purge-cb:checked){ border-color:var(--pf-danger, #dc2626); }
.pf-acard:has(.pf-purge-cb:checked) .pf-acard-select{ color:var(--pf-danger, #dc2626); }

/* The administrator's name inside a row: a second target, so it needs its own
   affordance without turning the cell into a link-coloured block. */
.pf-audit-actor{ font-size:.8125rem; font-weight:600; color:var(--pf-text);
  text-align:left; cursor:pointer; border-bottom:1px dotted var(--pf-border-strong);
  transition:color var(--pf-dur-1) var(--pf-ease); }
.pf-audit-actor:hover{ color:var(--pf-primary-ink); border-bottom-color:currentColor; }
.pf-audit-batch{ color:var(--pf-muted); }

.pf-audit-sec{ padding:1rem 0; border-bottom:1px solid var(--pf-border); }
.pf-audit-sec:first-child{ padding-top:.25rem; }
.pf-audit-sec:last-of-type{ border-bottom:0; }
.pf-audit-h{ font-size:.6875rem; font-weight:700; letter-spacing:.08em; text-transform:uppercase;
  color:var(--pf-muted); margin:0 0 .6rem; }

/* Label/value pairs. Two columns where there is room, one where there is not. */
.pf-audit-kv{ display:grid; grid-template-columns:repeat(auto-fit, minmax(11rem, 1fr));
  gap:.7rem 1.25rem; }
.pf-audit-kv > div{ min-width:0; }
.pf-audit-kv dt{ font-size:.6875rem; color:var(--pf-muted); margin-bottom:.15rem; }
.pf-audit-kv dd{ font-size:.8125rem; color:var(--pf-text); margin:0; overflow-wrap:anywhere; }

.pf-audit-reason{ margin-top:.9rem; padding:.7rem .85rem; border-radius:10px;
  background:var(--pf-surface-soft); border-left:3px solid var(--pf-border-strong); }

/* Before to after. Scrolls inside its own box so a long value cannot make the
   whole drawer scroll sideways. */
.pf-audit-diff-wrap{ overflow-x:auto; }
.pf-audit-diff{ width:100%; border-collapse:collapse; font-size:.8125rem; }
.pf-audit-diff th, .pf-audit-diff td{ text-align:left; padding:.45rem .6rem;
  border-bottom:1px solid var(--pf-border); vertical-align:top; }
.pf-audit-diff thead th{ font-size:.6875rem; font-weight:700; letter-spacing:.05em;
  text-transform:uppercase; color:var(--pf-muted); background:var(--pf-surface-soft); }
.pf-audit-diff tbody th{ font-weight:600; color:var(--pf-text); white-space:nowrap; }
.pf-audit-diff tbody tr:last-child th, .pf-audit-diff tbody tr:last-child td{ border-bottom:0; }
/* Direction of travel, carried by the design system's own semantic colours
   rather than an ad-hoc red/green pair. */
.pf-audit-before{ color:var(--pf-muted); text-decoration:line-through;
  text-decoration-color:var(--pf-border-strong); }
.pf-audit-after{ color:var(--pf-success); font-weight:600; }

.pf-audit-copy{ font-family:inherit; font-size:inherit; color:var(--pf-primary-ink);
  cursor:pointer; text-align:left; overflow-wrap:anywhere; }
.pf-audit-copy:hover{ text-decoration:underline; }

.pf-audit-batchlist{ list-style:none; margin:0; padding:0; display:flex;
  flex-direction:column; gap:.35rem; }
.pf-audit-batchlist li{ display:flex; align-items:center; justify-content:space-between;
  gap:.6rem; font-size:.8125rem; padding:.35rem .5rem; border-radius:8px;
  background:var(--pf-surface-soft); }
.pf-audit-batchlist a{ color:var(--pf-primary-ink); text-decoration:none; min-width:0;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pf-audit-batchlist a:hover{ text-decoration:underline; }

.pf-audit-foot{ font-size:.6875rem; color:var(--pf-muted); text-align:center;
  margin:1.25rem 0 0; padding-top:.9rem; border-top:1px dashed var(--pf-border); }

/* Administrator history panel */
.pf-audit-mini-stats{ display:grid; grid-template-columns:repeat(5, minmax(0,1fr));
  gap:.4rem; margin-bottom:1.1rem; }
.pf-audit-mini-stats > div{ text-align:center; padding:.55rem .25rem; border-radius:10px;
  background:var(--pf-surface-soft); border:1px solid var(--pf-border); }
.pf-audit-mini-n{ display:block; font-size:1rem; font-weight:700; color:var(--pf-text);
  font-variant-numeric:tabular-nums; }
.pf-audit-mini-l{ display:block; font-size:.625rem; letter-spacing:.04em;
  text-transform:uppercase; color:var(--pf-muted); margin-top:.1rem; }

.pf-audit-feed{ list-style:none; margin:0; padding:0; display:flex;
  flex-direction:column; gap:.4rem; }
.pf-audit-feed-btn{ display:block; width:100%; text-align:left; cursor:pointer;
  padding:.6rem .7rem; border-radius:10px; border:1px solid var(--pf-border);
  background:var(--pf-surface);
  transition:background-color var(--pf-dur-1) var(--pf-ease),
             border-color var(--pf-dur-1) var(--pf-ease); }
.pf-audit-feed-btn:hover{ background:var(--pf-surface-strong);
  border-color:var(--pf-border-strong); }
.pf-audit-feed-sum{ display:block; font-size:.8125rem; line-height:1.45;
  color:var(--pf-text); overflow-wrap:anywhere; }
.pf-audit-feed-meta{ display:flex; flex-wrap:wrap; align-items:center; gap:.35rem;
  margin-top:.4rem; }
.pf-audit-feed-time{ font-size:.6875rem; color:var(--pf-muted);
  font-variant-numeric:tabular-nums; margin-left:auto; }

/* A retained message body inside the panel. Preserves the sender's own line
   breaks without letting a long unbroken URL widen the panel. */
.pf-msg-body{ font-size:.875rem; line-height:1.6; color:var(--pf-text);
  background:var(--pf-surface-soft); border:1px solid var(--pf-border);
  border-radius:10px; padding:.85rem .95rem;
  overflow-wrap:anywhere; white-space:normal; }

/* A date field standing in for "Present" — disabled, and visibly so. Reduced
   opacity alone would read as a rendering glitch; the struck-through look plus
   the helper line under it says the field is not empty, it is superseded. */
.pf-dp-field.is-present{ opacity:.55; }
.pf-dp-field.is-present .pf-input{ cursor:not-allowed; background:var(--pf-surface-soft); }

/* =====================================================================
   RESPONSIVE PROJECT GALLERY (.pf-pgal)
   =====================================================================
   Mixed orientations that still look composed.

   THE PROBLEM WITH THE OBVIOUS ANSWERS. A uniform grid crops every portrait
   to a landscape box (or pads every landscape into a square) — it looks tidy
   and it destroys the work. A masonry column layout preserves the shapes but
   reads top-to-bottom-per-column, so the reading order stops matching the
   order the author chose, which for a portfolio is the whole point.

   SO: rows of items that keep their own ratio and share a height. Each tile
   declares its aspect in `--pf-a`, and `flex-grow` is set to that ratio, so a
   row of a 2:1 and a 1:1 gives the wide one twice the width and both come out
   the same height. That is a justified gallery, expressed in eight lines of
   flexbox, with the author's order intact left-to-right.

   NO LAYOUT SHIFT. `aspect-ratio` gives every tile its final height before its
   file has downloaded, so a ten-image gallery settles once. The ratio comes
   from the database (project_images.aspect), because CSS cannot know the shape
   of a file it has not fetched.

   PHONE: the same justified rows at a shorter target height, NOT a stack of
   full-width slabs. Blowing every image up to the column width is how a
   portrait ends up two screens tall and a set of five becomes a scroll
   marathon; compatible shapes still pair up at 360px, they just do it against
   a 9rem row instead of a 14rem one. */
.pf-pgal{ display:flex; flex-wrap:wrap; align-items:flex-start; gap:.75rem;
  --pf-pgal-h:14rem; }
.pf-pgal-item{
  margin:0; min-width:0;
  /* Basis AND grow are derived from the ratio, so everything in a row resolves
     to one height: a 2:1 beside a 1:1 gets twice the width and the same
     height. That is a justified gallery in two declarations, with the author's
     order intact left-to-right. */
  flex:var(--pf-a, 1.3333) 1 calc(var(--pf-a, 1.3333) * var(--pf-pgal-h));
  /* THE LAST-ROW CAP. flex-grow justifies a full row, but the final row has
     nothing to share with, so without a ceiling one leftover square inflates
     to the full width — the exact "lone image became a slab" complaint. 1.6x
     its natural size is enough slack to justify a real row and not enough to
     turn a straggler into a banner. */
  max-width:min(100%, calc(var(--pf-a, 1.3333) * var(--pf-pgal-h) * 1.6));
  aspect-ratio:var(--pf-a, 1.3333);
  border-radius:var(--pf-radius); overflow:hidden;
  background:var(--pf-surface-strong); }
/* The box IS the image's own ratio, so `cover` crops nothing — it is here to
   absorb sub-pixel rounding, never to reshape the picture. */
.pf-pgal-img{ display:block; width:100%; height:100%; object-fit:cover; }

@media (max-width:639.98px){
  .pf-pgal{ --pf-pgal-h:9rem; gap:.5rem; }
}

/* =====================================================================
   PROFILE PROJECT CARDS (.pf-projgrid)
   =====================================================================
   `.pf-bgrid` is the product's balanced grid and it is right for buttons and
   stat tiles, where a lone item filling the row is the desired answer. It is
   the wrong answer here: a person with ONE project got a single card stretched
   the full width of the page, cover and all, which reads as a hero banner
   rather than as the first of a set.

   auto-fit + minmax gives all three requirements from one line — one compact
   card when that is all there is, two side by side as soon as they fit, more
   as the container grows — and the 22rem ceiling is what stops any of them
   stretching. `justify-content:start` keeps a short row left-aligned instead of
   centring an orphan.

   The cover keeps `.pf-thumb` (16:9 + object-fit:cover), which is the ratio
   project covers are STORED at, so the box crops nothing and stretches
   nothing. */
.pf-projgrid{ display:grid; gap:1.25rem; justify-content:start; align-items:start;
  grid-template-columns:repeat(auto-fit, minmax(min(100%, 16rem), 22rem)); }

/* One image is not a gallery — it is a picture. It gets a height BUDGET of
   26rem, spent through its own ratio: a square settles at 26rem square, a 3:4
   stays portrait and narrow, a 16:9 uses the column, and a 5:2 runs out of
   width first and takes the height it is owed.

   THE WIDTH IS WHAT IS SET, NEVER THE HEIGHT. An explicit `height` beats
   `aspect-ratio`, so `height:26rem` + `max-width:100%` squashed a panorama the
   moment the clamp bit — a lone 5:2 measured 1.75 instead of 2.5, which is the
   one thing this gallery must never do. Setting width and letting
   `aspect-ratio` derive the height cannot distort, because there is only ever
   one free dimension. */
.pf-pgal-item:only-child{
  flex:0 0 auto; height:auto;
  width:min(100%, calc(var(--pf-a, 1.3333) * 26rem)); }
@media (max-width:639.98px){
  .pf-pgal-item:only-child{ width:100%; }
}

/* DONE AND CANCEL ARE NEVER OFF-SCREEN.
   The stage is sized against the viewport height in JS, which is the real fix;
   this is the safety net for the cases JS cannot predict — a landscape phone,
   a browser chrome bar appearing, a 200% zoom. The card scrolls and the action
   pair sticks to its bottom edge, so the two decisions this dialog exists to
   offer are reachable at any height. dvh (not vh) so a mobile URL bar sliding
   in does not push them under it.

   box-sizing is stated rather than inherited from Tailwind's preflight: this is
   a hard constraint, and under content-box the card's 1.5rem padding is ADDED
   to the limit, which is 48px of the dialog back off the bottom of the screen.
   A rule that only holds while another stylesheet happens to load first is not
   a constraint. */
.pf-crop-card{ box-sizing:border-box; width:min(94vw, 30rem);
  max-height:calc(100dvh - 2rem); overflow-y:auto; overscroll-behavior:contain; }

/* Crop-shape chips (.pf-crop-ratios) — only rendered where the shape is
   genuinely the user's choice. Small, textual, and never more than seven, so
   the row is scannable at 320px without becoming a second control to learn. */
.pf-crop-actions{ position:sticky; bottom:0; z-index:2; margin-top:1.4rem;
  padding-top:.75rem; background:var(--pf-surface); }
.pf-crop-ratios{ display:flex; flex-wrap:wrap; gap:.35rem; margin-bottom:.75rem; }
.pf-crop-ratio{ min-height:2.25rem; padding:.35rem .6rem; border-radius:999px;
  border:1px solid var(--pf-border); background:var(--pf-surface);
  font-size:.75rem; font-weight:600; color:var(--pf-muted); cursor:pointer;
  font-variant-numeric:tabular-nums;
  transition:border-color var(--pf-dur-1) var(--pf-ease),
             color var(--pf-dur-1) var(--pf-ease),
             background-color var(--pf-dur-1) var(--pf-ease); }
.pf-crop-ratio:hover{ color:var(--pf-text); border-color:var(--pf-border-strong); }
/* Selected state is border + fill + weight, never colour alone. */
.pf-crop-ratio.is-on{ background:var(--pf-primary-soft); border-color:var(--pf-primary-border);
  color:var(--pf-primary-ink); }
.pf-crop-ratio:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }
@media (max-width:639.98px){
  .pf-crop-ratio{ min-height:2.75rem; padding-inline:.8rem; }
}

/* ---- "More details" disclosure (.pf-more) --------------------------------
   Fields most people never need, moved out of the way rather than deleted —
   somebody who already stored a GPA must still be able to edit it. A native
   <details>, so it works with no JavaScript, is keyboard-operable for free,
   and is announced correctly without any ARIA of ours. */
.pf-more{ border-top:1px solid var(--pf-border); padding-top:.75rem; margin-top:.25rem; }
.pf-more-sum{ display:flex; align-items:center; gap:.35rem;
  min-height:2.75rem; margin:-.5rem 0; padding:.5rem 0;
  font-size:.8rem; font-weight:600; color:var(--pf-muted);
  cursor:pointer; list-style:none; -webkit-tap-highlight-color:transparent; }
.pf-more-sum::-webkit-details-marker{ display:none; }
/* The chevron is drawn here rather than inherited from the browser's marker,
   which is a different glyph in every engine. */
.pf-more-sum::before{ content:""; width:.5rem; height:.5rem; flex:0 0 auto;
  border-right:2px solid currentColor; border-bottom:2px solid currentColor;
  transform:rotate(-45deg); margin-right:.15rem;
  transition:transform var(--pf-dur-1) var(--pf-ease); }
.pf-more[open] .pf-more-sum::before{ transform:rotate(45deg); }
.pf-more-sum:hover{ color:var(--pf-text); }
.pf-more-sum:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px;
  border-radius:8px; }
.pf-more-body{ display:flex; flex-direction:column; gap:.75rem; padding-top:.5rem; }

/* ======================================================================
   PLATFORM ANALYTICS (.pf-an-*, .pf-arow*, .pf-chart*, .pf-funnel*)
   ======================================================================
   The vocabulary for /admin/stats and its drill-down panel. Everything here
   is a REPEATED object on that page â€” a ranked row, a small metric, a chart,
   a funnel step, a filter chip â€” which is the bar for adding to this file at
   all. Nothing is one-off styling and nothing declares a colour: every value
   below resolves to a token from the top of this file, so the analytics page
   is the same system as the rest of the product rather than a lookalike.

   Density is the point. This page carries roughly ninety numbers, so the type
   scale is one step down from the rest of the admin area, the ranked row is
   28px high, and the whitespace is spent on SEPARATING SECTIONS rather than on
   padding individual rows â€” a dense page reads calm when the groups are
   obvious, and cluttered when every item is given room to shout.
   ---------------------------------------------------------------------- */

/* ---- page furniture -------------------------------------------------- */
.pf-an-range{ display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; min-width:0; }
.pf-an-custom{ position:relative; }
.pf-an-custom > summary{ cursor:pointer; list-style:none; }
.pf-an-custom > summary::-webkit-details-marker{ display:none; }
/* Anchored to the RIGHT edge: this control sits at the end of the header row,
   so a left-anchored popover hangs off the viewport at every width. */
.pf-an-custom-form{ position:absolute; right:0; top:calc(100% + .4rem); z-index:40;
  display:flex; flex-wrap:wrap; align-items:flex-end; gap:.5rem; width:max-content;
  max-width:min(22rem, calc(100vw - 2rem)); padding:.75rem;
  background:var(--pf-surface); border:1px solid var(--pf-border);
  border-radius:var(--pf-radius); box-shadow:var(--pf-shadow-md, 0 8px 24px rgba(17,19,24,.10)); }
.pf-an-custom-form .pf-field{ flex:1 1 8rem; min-width:0; }
@media (max-width:479.98px){
  /* Below 480 the popover is wider than the room to its left, so it is pinned
     to the ROW rather than to the summary and cannot leave the screen. */
  .pf-an-custom{ position:static; }
  .pf-an-custom-form{ left:0; right:0; width:auto; max-width:none; }
}

.pf-an-alert{ margin-bottom:1rem; align-items:center; }
.pf-an-alert > span{ flex:1 1 auto; min-width:0; }

/* Section rule. The label sits ON the hairline, so the eye gets a group
   boundary for one line of vertical space instead of a whole card. */
.pf-an-sec{ display:flex; flex-wrap:wrap; align-items:baseline; gap:.5rem;
  margin:2rem 0 .85rem; padding-bottom:.5rem; border-bottom:1px solid var(--pf-border);
  font-size:.95rem; font-weight:700; color:var(--pf-text); }
.pf-an-sec > span{ font-size:.75rem; font-weight:500; color:var(--pf-muted); }
.pf-an-kpis{ margin-bottom:1.25rem; }
/* Twelve tiles two-up on a phone leaves the value ~70px beside an icon and a
   delta pill, and "1,130" was being clipped to "1,13". Letting the HEAD wrap
   drops the pill to its own line only on the tiles that actually need the
   room — a number is never squeezed, and no tile grows unless it must.
   (This is the useful half of .pf-kpi-grid; the other half, grid-auto-rows:1fr,
   is deliberately not used here — see the comment in views/admin/stats.php.) */
@media (max-width:479.98px){
  .pf-an-kpis .pf-kpi{ padding-inline:.75rem; }
  .pf-an-kpis .pf-kpi-head{ flex-wrap:wrap; gap:.4rem; }
  .pf-an-kpis .pf-kpi-value{ overflow-wrap:normal; }
}

/* Word-label switchers (chart metric, device breakdown).
   GRID, not the shared .pf-tabs flex-basis rule. That rule divides the row by a
   calc() basis, and these tabs carry .85rem of horizontal padding each — enough
   that two hypothetical items never fit one line, so all six ended up full
   width, one per row, six rows tall. An explicit column count cannot be talked
   out of it by padding. Three columns from 480, two below it. */
@media (max-width:639.98px){
  .pf-an-switch.pf-tabs{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr));
    gap:.25rem; width:100%; overflow-x:visible; }
  .pf-an-switch.pf-tabs .pf-tab{ min-width:0; justify-content:center;
    padding-inline:.4rem; font-size:.74rem; overflow:hidden; text-overflow:ellipsis; }
}
@media (max-width:399.98px){
  .pf-an-switch.pf-tabs{ grid-template-columns:repeat(2,minmax(0,1fr)); }
}
/* WHICH ONE AM I LOOKING AT. The shared `.pf-tab.active` is a white pill on a
   grey track, and on these two strips that is not enough: the range picker and
   the metric switcher are the only controls on the page whose selection you
   cannot read off the content — six metrics all draw the same chart shape, and
   "Sessions per day" in the heading is the only other clue. A filled pill makes
   the selection the loudest thing in the strip. `:hover` is restated because
   the generic `.pf-tab:hover` is declared LATER in this file at equal
   specificity and would otherwise repaint the selected tab grey under the
   pointer. */
.pf-an-switch .pf-tab.active, .pf-an-switch .pf-tab.active:hover,
.pf-tabs-compact .pf-tab.active, .pf-tabs-compact .pf-tab.active:hover{
  background:var(--pf-primary-ink); color:#fff; box-shadow:var(--pf-shadow-sm); }
.pf-an-switch .pf-tab.active > svg, .pf-tabs-compact .pf-tab.active > svg{ color:#fff; }
.pf-an-block{ margin-top:1.25rem; }
.pf-an-since{ display:flex; align-items:flex-start; gap:.4rem; margin:.75rem 0 0;
  font-size:.72rem; line-height:1.5; color:var(--pf-muted); }
.pf-an-since > svg{ flex:0 0 auto; margin-top:.15rem; }

/* ---- filter bar ------------------------------------------------------- */
/* The bar has TWO shapes, and which one you get is a question of room rather
   than of taste. Eight selects and an Apply button is a calm header row at
   1280px; at 390px it is two-up, ~240px tall, and it stands between the thumb
   and a report nobody has read yet. So below 768px the whole form collapses
   behind ONE button that opens the identical controls in the shared panel
   component (a bottom sheet on a phone) — same partial, same names, no second
   filter form. The chips stay outside the collapse at every width: what is
   narrowing the report has to be visible without opening anything. */
.pf-anbar-wrap{ margin-top:1rem; }
.pf-anbar{ padding:.85rem; }
.pf-anbar-wrap > .pf-anbar{ margin-top:0; }
/* Gated on `.pf-an-js` (set by analytics.js once it knows it can run): the
   sheet is FETCHED, so it only exists when that module does. Without it the
   full form stays on the page at every width rather than hiding behind a
   button that cannot open. */
.pf-anbar-open{ display:none; }
@media (max-width:767.98px){
  .pf-an-js .pf-anbar-wrap:not(.pf-anbar-inpanel) > .pf-anbar{ display:none; }
  .pf-an-js .pf-anbar-wrap:not(.pf-anbar-inpanel) > .pf-anbar-open{
    display:inline-flex; align-items:center; gap:.45rem; width:100%;
    justify-content:flex-start; }
}
/* The count IS the "filters are on" signal — a number in an accent pill beside
   the word, not a colour change somebody has to have seen the unfiltered
   version to notice. Its absence is stated too ("All traffic"), because a
   control that says nothing reads as a control that is not doing anything. */
.pf-anbar-count{ margin-left:auto; min-width:1.35rem; padding:.05rem .4rem; border-radius:999px;
  font-size:.7rem; font-weight:700; text-align:center;
  background:var(--pf-primary-ink); color:#fff; }
.pf-anbar-hint{ margin-left:auto; font-size:.72rem; font-weight:500; color:var(--pf-muted); }
.pf-anbar-wrap.is-filtered > .pf-anbar-open{ border-color:var(--pf-primary-ink); color:var(--pf-text); }
.pf-anbar-wrap.is-filtered > .pf-anbar{ border-color:var(--pf-primary-ink); }
/* Inside the sheet the card chrome is the sheet's, not the form's. */
.pf-anbar-inpanel > .pf-anbar{ border:0; box-shadow:none; padding:0; background:none; }
.pf-anbar-inpanel .pf-anbar-fields > .pf-field{ flex-basis:calc(50% - .3rem); max-width:none; }
.pf-anbar-inpanel .pf-anbar-act, .pf-anbar-inpanel .pf-anbar-act .pf-btn{
  flex:1 1 100%; width:100%; justify-content:center; }
.pf-anbar-fields{ display:flex; flex-wrap:wrap; gap:.6rem; align-items:flex-end; }
/* Grows to share a row, but capped — WITHOUT the cap the one control that
   wraps onto its own line stretches to the full card width and reads as a
   different, more important field than the seven above it. This is a form row,
   not a .pf-bgrid collection (memory.md §4.13b explicitly exempts field pairs),
   so filling the last row is the wrong goal here. */
.pf-anbar-fields > .pf-field{ flex:1 1 8.5rem; max-width:15rem; min-width:0; }
.pf-anbar-fields .pf-label{ font-size:.68rem; margin-bottom:.15rem; }
.pf-anbar-fields .pf-input{ width:100%; font-size:.8rem; padding-block:.35rem; min-height:2.25rem; }
.pf-anbar-act{ flex:0 0 auto; display:flex; align-items:flex-end; }
@media (max-width:479.98px){
  .pf-anbar-fields > .pf-field{ flex-basis:calc(50% - .3rem); }
  .pf-anbar-act, .pf-anbar-act .pf-btn{ flex:1 1 100%; width:100%; justify-content:center; }
}

.pf-chips{ display:flex; flex-wrap:wrap; align-items:center; gap:.4rem;
  margin-top:.75rem; padding-top:.7rem; border-top:1px solid var(--pf-border); }
/* The chips row is now a sibling of the form rather than its last child, so it
   carries the tinted edge that used to come from sitting inside the card. */
.pf-chips-on{ margin-top:.6rem; padding:.55rem .7rem; border:1px solid var(--pf-border);
  border-left:3px solid var(--pf-primary); border-radius:var(--pf-radius);
  background:var(--pf-surface-soft); }
.pf-anbar-inpanel .pf-chips-on{ margin-top:.85rem; }
.pf-chips-l{ font-size:.7rem; font-weight:700; text-transform:uppercase; letter-spacing:.05em;
  color:var(--pf-muted); }
/* The chip IS the remove control â€” the whole thing is the link that drops this
   filter â€” so the cross is decoration inside a 32px target rather than a second
   nested button inside a link, which is invalid markup and a keyboard trap. */
.pf-chip{ display:inline-flex; align-items:center; gap:.35rem; min-height:2rem;
  padding:.2rem .5rem .2rem .6rem; border-radius:999px; font-size:.75rem;
  background:var(--pf-primary-soft, #F7F6FE); color:var(--pf-text);
  border:1px solid var(--pf-border); max-width:100%;
  transition:background var(--pf-dur-1) var(--pf-ease), border-color var(--pf-dur-1) var(--pf-ease); }
.pf-chip:hover{ background:var(--pf-surface-strong); border-color:var(--pf-border-strong); }
.pf-chip:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:1px; }
.pf-chip-k{ font-weight:700; color:var(--pf-muted); flex:0 0 auto; }
.pf-chip-v{ font-weight:600; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pf-chip > svg{ flex:0 0 auto; color:var(--pf-muted); }

/* ---- analytics card --------------------------------------------------- */
.pf-acard-an{ display:flex; flex-direction:column; gap:.7rem; padding:1rem 1.1rem 1.1rem; min-width:0; }
.pf-an-head{ display:flex; align-items:flex-start; justify-content:space-between; gap:.6rem;
  flex-wrap:wrap; min-width:0; }
.pf-an-head-t{ min-width:0; flex:1 1 12rem; }
.pf-an-title{ display:flex; align-items:center; gap:.4rem; font-size:.85rem; font-weight:700;
  color:var(--pf-text); line-height:1.3; min-width:0; }
.pf-an-title > svg{ flex:0 0 auto; color:var(--pf-primary-ink); }
.pf-an-note{ font-size:.7rem; line-height:1.5; color:var(--pf-muted); margin-top:.2rem; max-width:52ch; }
.pf-an-foot{ font-size:.68rem; line-height:1.5; color:var(--pf-muted); margin-top:auto; padding-top:.2rem; }
.pf-an-tabs{ align-self:flex-start; }
.pf-empty-sm{ padding:1.5rem .75rem; }
.pf-empty-sm .pf-empty-icon{ width:2.5rem; height:2.5rem; border-radius:10px; margin-bottom:.6rem; }
.pf-empty-sm .pf-empty-title{ font-size:.85rem; }
.pf-empty-sm .pf-empty-text{ font-size:.75rem; }

/* ---- ranked row -------------------------------------------------------
   ONE row object for every breakdown in the report. Fixed tracks on both ends
   and a fluid label between them, so ten cards down the page keep their
   numbers on the same optical column even though the labels differ in length.
   The bar is last and narrow on purpose: it answers "roughly how big" at a
   glance and the digits answer "exactly how big" â€” swapping their emphasis is
   what makes a dense list unreadable. */
.pf-arows{ display:flex; flex-direction:column; gap:.1rem; list-style:none; margin:0; padding:0; }
.pf-arow-li{ min-width:0; }
.pf-arow{ display:flex; align-items:center; gap:.5rem; min-width:0;
  padding:.3rem .4rem; margin-inline:-.4rem; border-radius:8px;
  font-size:.8rem; color:var(--pf-text);
  transition:background-color var(--pf-dur-1) var(--pf-ease); }
a.pf-arow:hover{ background:var(--pf-surface-soft); }
a.pf-arow:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:-1px; }
.pf-arow-static{ cursor:default; }
.pf-arow-n{ flex:0 0 1rem; text-align:right; font-size:.68rem; color:var(--pf-muted-2);
  font-variant-numeric:tabular-nums; }
.pf-arow-ico{ flex:0 0 auto; display:inline-grid; place-items:center; width:1.25rem; height:1.25rem;
  border-radius:6px; background:var(--pf-surface-strong); color:var(--pf-muted); }
.pf-arow-lab{ flex:1 1 auto; min-width:0; display:flex; flex-direction:column; line-height:1.25; }
.pf-arow-text{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pf-arow-sub{ font-size:.65rem; color:var(--pf-muted-2); overflow:hidden; text-overflow:ellipsis;
  white-space:nowrap; }
.pf-arow-v{ flex:0 0 auto; font-weight:700; font-variant-numeric:tabular-nums; font-size:.78rem; }
.pf-arow-pct{ flex:0 0 2.6rem; text-align:right; font-size:.68rem; color:var(--pf-muted);
  font-variant-numeric:tabular-nums; }
.pf-arow-bar{ flex:0 0 2.75rem; height:.3rem; border-radius:999px;
  background:var(--pf-surface-strong); overflow:hidden; }
.pf-arow-bar > span{ display:block; height:100%; border-radius:999px; background:var(--pf-primary); }
@media (max-width:359.98px){
  /* At 320px the label is left with ~60px. The bar is the redundant element â€”
     the percentage beside it says the same thing in less space â€” so it goes. */
  .pf-arow-bar{ display:none; }
}

/* ---- trend chart ------------------------------------------------------
   The SVG stretches (preserveAspectRatio:none) so the CARD decides the width;
   strokes are non-scaling so they do not stretch with it. Hover targets are
   DOM elements laid over the top rather than rects inside the SVG, because a
   rect inside a stretched viewBox lands nowhere near the point it describes. */
.pf-chart{ position:relative; width:100%; }
.pf-chart > svg{ display:block; width:100%; height:11rem; overflow:visible; }
.pf-chart-grid{ stroke:var(--pf-border); stroke-width:1; }
.pf-chart-ax{ fill:var(--pf-muted-2); font-size:9px; font-family:inherit; }
.pf-chart-line{ fill:none; stroke:var(--pf-primary); stroke-width:2;
  stroke-linejoin:round; stroke-linecap:round; }
.pf-chart-ghost{ fill:none; stroke:var(--pf-muted-2); stroke-width:1.5; stroke-dasharray:3 3;
  stroke-linejoin:round; opacity:.75; }
.pf-chart-dot{ fill:var(--pf-primary); stroke:var(--pf-surface); stroke-width:2; }
.pf-chart-dot.is-last{ stroke-width:2.5; }
.pf-chart-hit{ position:absolute; inset:0; display:flex; }
.pf-chart-hit > span{ position:relative; flex:1 1 0; min-width:0; }
.pf-chart-hit > span:hover{ background:var(--pf-surface-soft); mix-blend-mode:multiply; }
/* CSS tooltip, not a scripted one. The chart lives inside [data-an-root], which
   analytics.js REPLACES on every range/metric/tab/filter change — anything
   bound to a node in here is bound to a node that is about to be thrown away,
   so a scripted tooltip works once and then silently stops. A :hover rule was
   never attached to anything, so it survives the swap. It also replaces the
   native title=, which took ~1s to appear and printed three facts as one line
   of OS chrome. */
.pf-chart-tip{ position:absolute; left:0; bottom:calc(100% - .5rem); z-index:5;
  display:none; flex-direction:column; gap:.1rem; width:max-content; max-width:12rem;
  padding:.4rem .55rem; border:1px solid var(--pf-border); border-radius:var(--pf-radius);
  background:var(--pf-surface); box-shadow:var(--pf-shadow-md, 0 8px 24px rgba(17,19,24,.10));
  font-size:.7rem; line-height:1.45; text-align:left; pointer-events:none; }
/* Days past the midpoint hang to the LEFT, or the last day's tooltip is drawn
   off the edge of the card. */
.pf-chart-tip.pf-tipd-l{ left:auto; right:0; }
.pf-chart-hit > span:hover .pf-chart-tip{ display:flex; }
.pf-chart-tipd{ font-weight:700; color:var(--pf-text); }
.pf-chart-tipv{ color:var(--pf-text); white-space:nowrap; }
.pf-chart-tipv > strong{ font-size:.85rem; }
/* The comparison line wears the ghost series' own colour, so the tooltip and
   the dashed line on the chart are readable as the same thing. */
.pf-chart-tipp{ color:var(--pf-muted-2); white-space:nowrap; }
.pf-chart-tipp::before{ content:''; display:inline-block; width:.7rem; height:0;
  margin-right:.3rem; vertical-align:middle;
  border-top:2px dashed var(--pf-muted-2); }
.pf-chart-x{ display:flex; justify-content:space-between; gap:.5rem; margin-top:.15rem;
  font-size:.65rem; color:var(--pf-muted-2); }
.pf-an-trend{ padding:1rem 1.1rem 1.1rem; display:flex; flex-direction:column; gap:.7rem; }
.pf-an-trend .pf-chart > svg{ height:12.5rem; }
.pf-an-legend{ display:flex; flex-wrap:wrap; align-items:center; gap:.3rem;
  font-size:.68rem; color:var(--pf-muted); }
.pf-legend-k{ display:inline-block; width:1.1rem; height:0; border-top-width:2px;
  border-top-style:solid; }
.pf-legend-k:not(:first-child){ margin-left:.75rem; }
.pf-legend-now{ border-top-color:var(--pf-primary); }
.pf-legend-prev{ border-top-style:dashed; border-top-color:var(--pf-muted-2); }

/* ---- small metric ------------------------------------------------------ */
.pf-mini{ min-width:0; padding:.55rem .1rem; }
.pf-mini-v{ font-size:1.15rem; font-weight:800; font-variant-numeric:tabular-nums;
  line-height:1.15; color:var(--pf-text); white-space:nowrap; }
.pf-mini-k{ font-size:.72rem; font-weight:600; color:var(--pf-text); margin-top:.1rem;
  overflow-wrap:anywhere; }
.pf-mini-s{ font-size:.65rem; color:var(--pf-muted); line-height:1.4; margin-top:.1rem;
  overflow-wrap:anywhere; }

/* ---- cohort grid ------------------------------------------------------
   Five steps, ONE hue. A rainbow scale would imply the cells differ in KIND;
   they differ in DEGREE, so only the tint moves. The percentage is printed in
   every cell, so colour is never the sole carrier of the value. */
.pf-cohort td, .pf-cohort th{ padding:.5rem .7rem; }
.pf-cohort-c{ font-variant-numeric:tabular-nums; }
.pf-cohort-0{ color:var(--pf-muted-2); }
.pf-cohort-1{ background:rgb(var(--pf-primary-rgb) / 8%); }
.pf-cohort-2{ background:rgb(var(--pf-primary-rgb) / 16%); }
.pf-cohort-3{ background:rgb(var(--pf-primary-rgb) / 26%); }
.pf-cohort-4{ background:rgb(var(--pf-primary-rgb) / 38%); font-weight:700; }
.pf-cohort-na{ color:var(--pf-muted-2); }

/* ---- funnel ------------------------------------------------------------ */
.pf-funnel{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.55rem; }
.pf-funnel-step{ min-width:0; }
.pf-funnel-top{ display:flex; align-items:baseline; justify-content:space-between; gap:.5rem;
  font-size:.78rem; }
.pf-funnel-lab{ display:inline-flex; align-items:center; gap:.15rem; min-width:0; font-weight:600; }
.pf-funnel-v{ flex:0 0 auto; font-weight:800; font-variant-numeric:tabular-nums; }
.pf-funnel-pct{ font-size:.68rem; font-weight:600; color:var(--pf-muted); margin-left:.25rem; }
.pf-funnel-bar{ height:.5rem; margin-top:.25rem; border-radius:999px;
  background:var(--pf-surface-strong); overflow:hidden; }
.pf-funnel-bar > span{ display:block; height:100%; border-radius:999px; background:var(--pf-primary); }
.pf-funnel-drop{ display:flex; align-items:center; gap:.2rem; margin-top:.2rem;
  font-size:.65rem; color:var(--pf-muted); }
.pf-funnel-drop > svg{ flex:0 0 auto; }

/* ---- conversions ------------------------------------------------------- */
.pf-conv{ display:flex; flex-direction:column; gap:.35rem; }
.pf-conv-row{ display:flex; align-items:center; gap:.5rem; font-size:.8rem; min-width:0; }
.pf-conv-l{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pf-conv-v{ flex:0 0 auto; font-weight:800; font-variant-numeric:tabular-nums; }

/* ---- excluded-traffic strip -------------------------------------------- */
.pf-an-excl{ display:flex; flex-wrap:wrap; align-items:center; gap:.35rem 1rem;
  padding:.6rem .75rem; border-radius:10px; background:var(--pf-surface-soft);
  font-size:.72rem; color:var(--pf-muted); }
.pf-an-excl > strong{ color:var(--pf-text); }

/* ---- drill-down panel --------------------------------------------------
   Lives INSIDE the shared .pf-panel component, so it styles content only â€” no
   positioning, no scrim, no gesture, no focus handling of its own. */
.pf-drill-id{ display:flex; align-items:center; gap:.6rem; flex:1 1 auto; min-width:0; }
.pf-drill-title{ font-size:1rem; font-weight:700; line-height:1.35; color:var(--pf-text);
  overflow-wrap:anywhere; }
.pf-drill-title:focus{ outline:none; }
.pf-drill-sub{ font-size:.7rem; color:var(--pf-muted); margin-top:.1rem; }
/* The drill body IS the panel's scrolling region — it carries .pf-panel-content
   as well, which owns the overflow, the min-height:0 and the padding. This rule
   adds only the internal rhythm; it must NOT set padding or overflow, or it
   would fight the component it lives inside. That fight is exactly the bug this
   panel had: a body with neither `overflow-y` nor `min-height:0` inside a card
   whose overflow is hidden, so anything past the fold was unreachable. */
.pf-drill-body{ display:flex; flex-direction:column; gap:1rem; }
.pf-drill-note{ font-size:.72rem; }
.pf-drill-kpis .pf-kpi{ padding:.6rem .7rem; }
.pf-drill-kpis .pf-kpi-value{ font-size:1.05rem; }
.pf-drill-kpis .pf-kpi-label{ font-size:.65rem; }
/* Same reason as .pf-an-kpis above: in a sheet the tile is ~160px wide and the
   delta pill has to be allowed onto its own line, or the NUMBER is the thing
   that gets squeezed — and a clipped number is a wrong number. */
@media (max-width:639.98px){
  .pf-drill-kpis .pf-kpi-head{ flex-wrap:wrap; gap:.35rem; }
}
.pf-drill-meta{ display:flex; flex-wrap:wrap; gap:.35rem 1rem; font-size:.72rem; color:var(--pf-muted); }
.pf-drill-meta strong{ color:var(--pf-text); font-variant-numeric:tabular-nums; }
.pf-drill-chart{ padding:.5rem .25rem 0; border-radius:10px; background:var(--pf-surface-soft); }
.pf-drill-chart .pf-chart > svg{ height:5.5rem; }
.pf-drill-sec{ display:flex; flex-direction:column; gap:.4rem; }
.pf-drill-h3{ display:flex; align-items:center; gap:.35rem; font-size:.72rem; font-weight:700;
  text-transform:uppercase; letter-spacing:.05em; color:var(--pf-muted); }
.pf-drill-h3 > svg{ flex:0 0 auto; }
.pf-drill-actions{ display:flex; flex-wrap:wrap; gap:.5rem; padding-top:.75rem;
  border-top:1px solid var(--pf-border); }
.pf-drill-actions .pf-btn{ flex:1 1 10rem; justify-content:center; }
/* A sub-breakdown's heading and its own "view all", on one baseline. */
.pf-drill-sechead{ display:flex; align-items:baseline; justify-content:space-between; gap:.5rem;
  flex-wrap:wrap; }
.pf-drill-sechead .pf-an-note{ margin:0; }

/* =====================================================================
   THE IN-PLACE ANALYTICS REGION  (.pf-an-root, driven by analytics.js)
   =====================================================================
   Everything between the page title and the end of the report lives inside
   ONE element, and changing a range, a tab or a filter replaces exactly that
   element. Two rules matter here and nothing else does:

     · the region must not COLLAPSE while a request is out, or the page under
       the pointer jumps by its own height and back — which is the thing this
       whole mechanism exists to prevent. `min-height` holds the floor.
     · the dimming is a 180ms-delayed attribute set by the script, so a fast
       answer never flashes grey. The transition here is only for the case
       where it does appear. */
/* `overflow-anchor:none` is load-bearing, not a tweak. Chrome's scroll
   anchoring keeps a chosen element still when content around it resizes, which
   is right when a lazy image loads and WRONG when the entire subtree is
   replaced: the element it anchored to no longer exists, it picks another, and
   the page slides by up to 166px AFTER analytics.js has already put the offset
   back. Switching it off for this subtree is what makes "the viewport does not
   move" a property rather than a hope. */
.pf-an-root{ min-height:60vh; overflow-anchor:none; }
.pf-an-root[data-an-busy]{ opacity:.55; pointer-events:none;
  transition:opacity var(--pf-dur-1) var(--pf-ease); }
.pf-an-root[data-an-busy] *{ cursor:progress; }
@media (prefers-reduced-motion:reduce){
  .pf-an-root[data-an-busy]{ transition:none; }
}

/* The period picker and an action button side by side in the page head. */
.pf-an-headact{ display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; min-width:0; }

/* A filter field that is a search box wants the room two selects would take. */
.pf-anbar-fields > .pf-field-wide{ flex:2 1 16rem; max-width:28rem; }

/* The search box that sits in a card header (the session log). */
.pf-an-logsearch{ flex:0 1 20rem; min-width:0; }
.pf-an-logsearch .pf-search{ width:100%; }
@media (max-width:767.98px){
  .pf-an-logsearch{ flex:1 1 100%; }
}

/* ---- pager ------------------------------------------------------------
   Three items, and the middle one is the answer to "where am I" — a pager
   that only offers Previous/Next makes a reader count. */
.pf-an-pager{ display:flex; align-items:center; justify-content:center; gap:.75rem;
  flex-wrap:wrap; margin-top:.85rem; padding-top:.85rem; border-top:1px solid var(--pf-border); }
.pf-an-pagerv{ font-size:.75rem; color:var(--pf-muted); font-variant-numeric:tabular-nums; }
.pf-an-pager .is-disabled{ opacity:.45; pointer-events:none; }

/* ---- empty-state action ------------------------------------------------ */
.pf-empty-act{ margin-top:.75rem; display:flex; flex-wrap:wrap; gap:.5rem; justify-content:center; }

/* ---- a mini tile that holds a small list rather than one number -------- */
.pf-mini-list{ text-align:left; }
.pf-mini-row{ display:flex; align-items:baseline; justify-content:space-between; gap:.5rem;
  font-size:.72rem; color:var(--pf-muted); margin-top:.2rem; }
.pf-mini-row > strong{ color:var(--pf-text); font-variant-numeric:tabular-nums; }

/* =====================================================================
   VISITORS — the session log, the session panel
   =====================================================================
   ONE dataset, TWO structures, swapped at 640px — the same rule the Activity,
   Audit and Contact-archive screens follow.

     .pf-vis-table   a real <table>, from 640px up. The columns ARE the
                     comparison there: six sessions from one city on one device
                     is a pattern you see by scanning a column.
     .pf-vis-cards   a real <ul>, below it. Seven columns do not become a phone
                     layout by being narrower — measured at 390px the visitor
                     column was 50px wide, "Anonymous" broke as "Anony mous",
                     and four columns were off the screen.

   Each is display:none at the other's width, which removes it from the
   accessibility tree as well as the layout, so a screen reader is handed one
   correct structure rather than a table whose rows have stopped being rows. */
.pf-vis-cards{ display:flex; flex-direction:column; gap:.5rem; list-style:none; margin:0; padding:0; }
.pf-vis-table{ display:none; }
@media (min-width:640px){
  .pf-vis-cards{ display:none; }
  .pf-vis-table{ display:block; }
}
/* Below ~900px the seven columns still have to scroll rather than compress —
   a table wrapper without a min-width on its table is a table that squashes. */
.pf-vis-log{ min-width:56rem; }
.pf-vis-log td{ vertical-align:top; }

.pf-vis-card{ display:flex; flex-direction:column; gap:.3rem; padding:.7rem .8rem;
  border:1px solid var(--pf-border); border-radius:12px; background:var(--pf-surface);
  cursor:pointer; min-width:0;
  transition:background-color var(--pf-dur-1) var(--pf-ease), border-color var(--pf-dur-1) var(--pf-ease); }
.pf-vis-card:hover{ background:var(--pf-surface-soft); }
.pf-vis-card:focus-visible{ outline:2px solid var(--pf-primary-ink); outline-offset:2px; }
.pf-vis-cardtop{ display:flex; align-items:baseline; justify-content:space-between; gap:.5rem; }
.pf-vis-cardtop .pf-vis-who{ font-size:.85rem; overflow-wrap:normal; }
.pf-vis-when{ flex:0 0 auto; font-size:.68rem; color:var(--pf-muted); white-space:nowrap; }
.pf-vis-cardline{ display:flex; align-items:center; gap:.35rem; min-width:0;
  font-size:.75rem; color:var(--pf-text); }
.pf-vis-cardline > svg{ flex:0 0 auto; color:var(--pf-muted-2); }
.pf-vis-cardline > span,
.pf-vis-cardline .pf-country-name{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pf-vis-cardfoot{ display:flex; flex-wrap:wrap; gap:.15rem .6rem; margin-top:.15rem;
  font-size:.68rem; color:var(--pf-muted); }
.pf-vis-cardfoot strong{ color:var(--pf-text); font-variant-numeric:tabular-nums; }
.pf-vis-cardip{ margin-top:.2rem; }
.pf-vis-who{ display:block; font-weight:600; overflow-wrap:anywhere; }
.pf-vis-sub{ display:block; font-size:.68rem; color:var(--pf-muted); line-height:1.35; margin-top:.1rem; }
.pf-vis-path{ max-width:14rem; }
.pf-vis-path .pf-arow-text{ display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }

/* The journey: what this session opened, in order. Numbered so "third page"
   is readable without counting, and timed so a five-second bounce and a
   twenty-minute read look different. */
.pf-vis-journey{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.15rem;
  counter-reset:none; }
.pf-vis-journey > li{ display:flex; align-items:flex-start; gap:.5rem; min-width:0;
  padding:.35rem .4rem; border-radius:8px; }
.pf-vis-journey > li:nth-child(odd){ background:var(--pf-surface-soft); }
.pf-vis-jn{ flex:0 0 1.25rem; text-align:right; font-size:.68rem; color:var(--pf-muted-2);
  font-variant-numeric:tabular-nums; line-height:1.5; }
.pf-vis-jp{ flex:1 1 auto; min-width:0; font-size:.78rem; }
.pf-vis-jt{ flex:0 0 auto; font-size:.68rem; color:var(--pf-muted); font-variant-numeric:tabular-nums;
  line-height:1.5; }

/* The account list inside a city panel. */
.pf-vis-people{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.1rem; }
.pf-vis-people .pf-arow{ gap:.6rem; }

@media (prefers-reduced-motion:reduce){
  .pf-arow, .pf-chip{ transition:none; }
}
