@charset "UTF-8";
/* ==========================================================================
   Persona Studio v2 — app.css
   Layout + every component. Loaded after css/tokens.css (the vocabulary) and
   before css/site-chrome.css (the shared-nav overrides).

   THREE RULES THIS FILE IS BUILT AROUND
   1. No raw colour, spacing, radius, font-size, duration or easing literal.
      Everything resolves through a --ps-* token. The one exception the language
      forces is the pixel value inside a @media query, because custom properties
      cannot be evaluated there; the canonical five are tokens.css §1.11.
   2. Zero `!important`. The token layer has no cascade to fight and neither
      does this one — `hidden` is handled with :not([hidden]) instead.
   3. Hover never changes layout size. v1's card hover un-clamped the
      description and grew the card ~350px, bursting the grid row.

   Contents
     1  Base layer / reset          (Reboot is gone — head.php handed this here)
     2  Page ground + aurora
     3  Shell layout
     4  Sidebar (docked + off-canvas)
     5  Buttons, icon buttons, chips, badges, tags
     6  Discover: heading, filters, grid, card
     7  Persona detail + about
     8  Dialogs
     9  Chat surface
    10  Forms
    11  Lists (leaderboard, history, my personas, announcements)
    12  Feedback states: skeleton, empty, error, toast
    13  Utilities
   ========================================================================== */


/* ==========================================================================
   1. BASE LAYER / RESET
   partials/head.php dropped Bootstrap (2.6% used) and with it Reboot, and
   handed the reset to css/. tokens.css predates that decision and has none, so
   it lives here. /css/nav-shared.css loads BEFORE this file and assumes
   border-box while declaring no font-family, so both have to be set globally
   rather than scoped to .ps-*.
   ========================================================================== */
*,
*::before,
*::after { box-sizing: border-box; }

html {
  /* The deep ground. It is on <html> rather than <body> so that the aurora in
     §2 — a fixed pseudo-element at z-index -1 — has something to paint over. */
  background-color: var(--ps-bg-deep);
  -webkit-text-size-adjust: 100%;
          text-size-adjust: 100%;
  /* The site nav is position:sticky, so an in-page anchor (#ps-discover-title)
     would otherwise land under it. */
  scroll-padding-top: calc(var(--ps-nav-h) + var(--ps-s-4));
  tab-size: 4;
  /* Reserve the scrollbar's gutter permanently, so opening a dialog — which puts
     `overflow: hidden` on this element via .ps-scroll-lock (§8) — cannot shift
     the whole page sideways by the scrollbar's width. */
  scrollbar-gutter: stable;
}

@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

body {
  margin: 0;
  min-height: 100dvh;
  /* Transparent on purpose: <html> carries the colour, body::before the aurora. */
  background-color: transparent;
  color: var(--ps-text-1);
  font-family: var(--ps-font-sans);
  font-size: var(--ps-fs-md);
  font-weight: var(--ps-fw-normal);
  line-height: var(--ps-lh-normal);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

h1, h2, h3, h4, h5, h6 {
  margin: 0;
  font-family: var(--ps-font-display);
  font-weight: var(--ps-fw-semibold);
  line-height: var(--ps-lh-snug);
  letter-spacing: var(--ps-ls-tight);
  text-wrap: balance;
}

p, dl, dd, figure, blockquote, pre { margin: 0; }

ul, ol { margin: 0; padding: 0; list-style: none; }

/* Prose contexts opt list markers back in — the reset strips them globally
   because 90% of the lists in this product are layout, not content. */
.ps-dialog__body ul:not(.ps-ranked, .ps-minelist, .ps-notiflist, .ps-chatlist, .ps-taglist, .ps-starters__list) {
  padding-left: var(--ps-s-5);
  list-style: disc;
}

a { color: var(--ps-accent-text); text-decoration: underline; }
a:hover { color: var(--ps-text-1); }

img, svg, video, canvas { display: block; }
img, video { max-width: 100%; height: auto; }

/* The icon sprite is an <svg hidden> in the HTML namespace of the document but
   an SVG element, and the UA `[hidden]{display:none}` rule does not reach it —
   measured 300x150 of blank space at the top of the page before this rule.

   Written TWICE on purpose. `[hidden]` is (0,1,0), the same weight as any of the
   component classes below that declare a `display`, and those come later in the
   file — so a single `[hidden]` LOST. Measured: the composer's Stop button
   (`hidden` in partials/chat.php, `.ps-iconbtn { display: inline-grid }`) painted
   as an empty 44x44 box beside Send on every idle chat, and chat.js's
   "Jump to latest" (`hidden`, `.ps-btn { display: inline-flex }`) was visible
   permanently — two controls showing in the one state they must not.

   `[hidden][hidden]` is (0,2,0), so it outranks every single-class rule in this
   file while staying a plain declaration: this is the alternative to the
   `!important` the budget forbids. Components that need a display when NOT hidden
   say so with `:not([hidden])`, which is also (0,2,0) but cannot match at the
   same time, so the two can never disagree. */
[hidden],
[hidden][hidden] { display: none; }
.ps-sprite { display: none; }

button, input, select, textarea, optgroup {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  margin: 0;
}

button, [type="button"], [type="submit"], [type="reset"] {
  background-color: transparent;
  border: 0;
  padding: 0;
  cursor: pointer;
  text-align: inherit;
  -webkit-appearance: none;
          appearance: none;
}

button:disabled, input:disabled, select:disabled, textarea:disabled {
  cursor: not-allowed;
  color: var(--ps-text-disabled);
}

textarea { resize: vertical; }

fieldset { margin: 0; padding: 0; border: 0; min-inline-size: 0; }
legend { padding: 0; }

table { border-collapse: collapse; }

hr {
  border: 0;
  border-top: var(--ps-bw) solid var(--ps-border);
  margin-block: var(--ps-s-6);
}

code, kbd, samp, pre { font-family: var(--ps-font-mono); font-size: var(--ps-fs-sm); }

::selection { background-color: var(--ps-select-bg); color: var(--ps-select-fg); }

::placeholder { color: var(--ps-text-3); opacity: 1; }

/* --- focus: one language, everywhere -------------------------------------
   v1 had no focus token and three competing focus treatments plus controls
   with none at all. Outline is the default because it never affects layout;
   components inside an overflow:hidden box switch to the box-shadow variant. */
:focus-visible {
  outline: var(--ps-focus-outline);
  outline-offset: var(--ps-focus-offset);
  border-radius: var(--ps-r-1);
}
:focus:not(:focus-visible) { outline: none; }

/* Everything that moves reads a duration token, so tokens.css §6 can collapse
   them all at once; nothing here needs its own reduced-motion branch. §12
   handles the two infinite animations, which a duration collapse cannot stop. */


/* ==========================================================================
   2. PAGE GROUND + AURORA
   v1's aurora targeted `.persona-studio`, a class that appears nowhere in the
   markup, so the signature background never rendered — which is also why its
   glassmorphism read grey: backdrop-filter over a flat colour has nothing to
   refract. This one is attached to the element that certainly exists.
   ========================================================================== */
.ps-body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  /* Bounded once, on the wrapper, so overlapping blobs cannot compound and the
     brightest point on the page stays at --ps-aurora-max. */
  opacity: var(--ps-aurora-opacity);
  background-image:
    radial-gradient(var(--ps-aurora-size) var(--ps-aurora-size) at 12% 8%,  var(--ps-aurora-1), transparent 70%),
    radial-gradient(var(--ps-aurora-size) var(--ps-aurora-size) at 88% 2%,  var(--ps-aurora-2), transparent 68%),
    radial-gradient(var(--ps-aurora-size) var(--ps-aurora-size) at 62% 58%, var(--ps-aurora-3), transparent 65%);
  background-repeat: no-repeat;
  filter: blur(var(--ps-aurora-blur));
  /* Its own compositor layer: the blur is then rasterised once instead of on
     every scroll frame. */
  will-change: transform;
  animation: ps-aurora-drift var(--ps-dur-aurora) var(--ps-ease-in-out) infinite alternate;
}

@keyframes ps-aurora-drift {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(2%, -1.5%, 0) scale(1.08); }
}

/* Reduced motion keeps the aurora but stops the drift (tokens.css §6 sets the
   period to 0s; this makes the intent explicit for the animation shorthand). */
@media (prefers-reduced-motion: reduce) {
  .ps-body::before { animation: none; }
}

/* Skip link: present for keyboard users, out of the way for everyone else.
   Unstyled it rendered as visible body text above the banner. */
.ps-skip {
  position: fixed;
  top: var(--ps-s-2);
  left: var(--ps-s-2);
  z-index: var(--ps-z-toast);
  transform: translateY(calc(-100% - var(--ps-s-8)));
  padding: var(--ps-s-3) var(--ps-s-5);
  border-radius: var(--ps-r-control);
  background-color: var(--ps-accent);
  color: var(--ps-on-accent);
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-semibold);
  text-decoration: none;
  box-shadow: var(--ps-e-3);
  transition: transform var(--ps-t-base);
}
.ps-skip:focus-visible {
  transform: translateY(0);
  color: var(--ps-on-accent);
  outline-color: var(--ps-focus-media);
}


/* ==========================================================================
   3. SHELL LAYOUT
   persona-studio.php ~line 525: app.css must NOT give the app a height:100vh
   inner-scroll shell. v1's `calc(100vh - 64px)` panes made the footer
   unreachable and produced two simultaneous scrollbars (measured: <body> 995px
   in a 900px viewport AND .personas-container 2948px in 773px). So the shell is
   ordinary document flow, the page is the only scroller, and the sidebar is
   sticky rather than fixed — which also means no nav height is hardcoded
   anywhere: --ps-nav-h is the single source and app.js keeps it honest.
   ========================================================================== */
.ps-shell {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--ps-s-4);
  inline-size: 100%;
  max-inline-size: var(--ps-shell-max);
  margin-inline: auto;
  padding-inline: var(--ps-s-4);
}

.ps-main {
  /* min-inline-size on a grid child is what stops a wide word or an unsized
     image from pushing the whole page sideways. */
  min-inline-size: 0;
  padding-block: var(--ps-s-6) var(--ps-s-11);
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-9);
}

@media (min-width: 1024px) {
  .ps-shell {
    /* 1440 - (2 x 20 padding) - 264 sidebar - 24 gap = 1112
       = (4 x 260 --ps-card-min) + (3 x 24 grid gap): exactly four across. */
    grid-template-columns: var(--ps-sidebar-w) minmax(0, 1fr);
    column-gap: var(--ps-s-6);
    padding-inline: var(--ps-s-5);
  }
  .ps-main { padding-block: var(--ps-s-8) var(--ps-s-12); }
}

@media (min-width: 1600px) {
  .ps-shell { padding-inline: var(--ps-s-7); }
}


/* ==========================================================================
   4. SIDEBAR
   Docked from 1024px, an off-canvas drawer below it. In v1 it was permanently
   off-screen at `left: -240px` below 868px with no affordance to reveal it, so
   Discover, Leaderboard, Create, My Personas and the usage meter were
   unreachable on every phone and tablet; and it hardcoded `top: 64px` against a
   real nav bottom of 207px, with z-index 30 against the nav's 100, so the logo
   was clipped by the nav at scrollTop 0.

   State comes from the toggle's aria-expanded — the accessibility state IS the
   styling state, so the two cannot disagree. Behaviour is the JS agent's;
   nothing here keys off a data-ps-action value (INTERFACES §5).
   ========================================================================== */
.ps-sidebar { min-inline-size: 0; }

.ps-sidebar__toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--ps-s-2);
  min-height: var(--ps-tap-min);
  margin-block-start: var(--ps-s-4);
  padding: var(--ps-s-2) var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-border-strong);
  border-radius: var(--ps-r-pill);
  background-color: var(--ps-surface-1);
  color: var(--ps-text-1);
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-medium);
  box-shadow: var(--ps-e-1);
  transition: background-color var(--ps-t-base), border-color var(--ps-t-base);
}
.ps-sidebar__toggle:hover { background-color: var(--ps-surface-1-hover); }
.ps-sidebar__toggle:active { background-color: var(--ps-surface-1-active); }

.ps-sidebar__panel {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-5);
  padding: var(--ps-s-5) var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-glass-hairline);
  border-radius: var(--ps-r-card);
  /* Real glass, because §2 gave it a real backdrop to refract. */
  background-color: var(--ps-glass-tint);
  background-image: var(--ps-grad-sheen);
  -webkit-backdrop-filter: blur(var(--ps-glass-blur)) saturate(1.4);
          backdrop-filter: blur(var(--ps-glass-blur)) saturate(1.4);
  box-shadow: var(--ps-e-2);
}

.ps-sidebar__brand {
  display: flex;
  align-items: center;
  gap: var(--ps-s-2);
}
.ps-sidebar__mark {
  display: grid;
  place-items: center;
  inline-size: var(--ps-icon-xl);
  block-size: var(--ps-icon-xl);
  border-radius: var(--ps-r-2);
  background-image: var(--ps-grad-brand);
  color: var(--ps-on-accent);
  box-shadow: var(--ps-e-glow);
}
.ps-sidebar__name {
  flex: 1 1 auto;
  /* 264px of sidebar leaves ~176px beside the mark: the product name gets it,
     and the Beta badge wraps under it rather than breaking "Persona Studio"
     across two lines. */
  white-space: nowrap;
  font-family: var(--ps-font-display);
  font-size: var(--ps-fs-md);
  font-weight: var(--ps-fw-bold);
  letter-spacing: var(--ps-ls-tight);
}

.ps-sidebar__brand { flex-wrap: wrap; }
/* Off-canvas only (partials/sidebar.php >>> HANDOFF). Small PAINT, full-size
   TARGET, the same trade as .ps-btn--sm and for the same reason: measured at
   390px the drawer is 285px wide and the brand row already spends 246 of its 253
   available pixels on the mark, "Persona Studio" and the Beta badge. A 44px box
   wrapped to a second line and left a lone ✕ under the product name; pinning it
   absolutely instead pushed the Beta badge onto that second line. At a 24px paint
   the four items fit one row with 7px to spare, and the ::after restores the
   44px hit area by overhanging 10px on every side — the brand row's other
   children are not interactive, so the overhang cannot steal a click.
   Hidden entirely at the docked breakpoint, below.

   Scoped through the parent on purpose: `.ps-sidebar__close` alone is (0,1,0),
   the same weight as `.ps-iconbtn` which sizes it to 44px and which comes LATER
   in this file, so the size below silently lost and the button stayed 44px and
   still wrapped. (0,2,0) settles it without reordering the sheet. */
.ps-sidebar__brand > .ps-sidebar__close {
  position: relative;
  flex: none;
  inline-size: var(--ps-icon-lg);
  block-size: var(--ps-icon-lg);
  margin-inline-start: auto;
}
.ps-sidebar__brand > .ps-sidebar__close::after {
  content: "";
  position: absolute;
  inset: calc((var(--ps-tap-min) - var(--ps-icon-lg)) / -2);
}

.ps-sidebar__list { display: flex; flex-direction: column; gap: var(--ps-s-h); }

.ps-sidebar__item {
  display: flex;
  align-items: center;
  gap: var(--ps-s-3);
  inline-size: 100%;
  min-height: var(--ps-tap-min);
  padding: var(--ps-s-2) var(--ps-s-3);
  border-radius: var(--ps-r-2);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-medium);
  text-decoration: none;
  transition: background-color var(--ps-t-fast), color var(--ps-t-fast);
}
/* A fixed icon column. v1 sized these at 26/20/20px with no box, so the
   sidebar's icons did not line up with each other at any width. */
.ps-sidebar__item > .ps-icon {
  flex: none;
  inline-size: var(--ps-icon-box);
  block-size: var(--ps-icon-md);
  color: var(--ps-text-3);
  transition: color var(--ps-t-fast);
}
.ps-sidebar__item > span:not(.ps-visually-hidden) { flex: 1 1 auto; min-inline-size: 0; }

.ps-sidebar__item:hover {
  background-color: var(--ps-surface-2);
  color: var(--ps-text-1);
}
.ps-sidebar__item:hover > .ps-icon { color: var(--ps-accent-ui); }
.ps-sidebar__item:active { background-color: var(--ps-surface-2-active); }

.ps-sidebar__item[aria-current="page"] {
  background-color: var(--ps-accent-tint);
  color: var(--ps-text-1);
  box-shadow: inset var(--ps-bw-strong) 0 0 var(--ps-accent);
}
.ps-sidebar__item[aria-current="page"] > .ps-icon { color: var(--ps-accent-ui); }

.ps-sidebar__hint-icon { color: var(--ps-text-3); }

.ps-sidebar__count {
  flex: none;
  padding: var(--ps-s-h) var(--ps-s-2);
  border-radius: var(--ps-r-pill);
  background-color: var(--ps-surface-3);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-xs);
  font-variant-numeric: tabular-nums;
}

.ps-sidebar__foot {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-3);
  margin-block-start: auto;
  padding-block-start: var(--ps-s-4);
  border-block-start: var(--ps-bw) solid var(--ps-border);
}

.ps-sidebar__item--quiet { color: var(--ps-text-3); font-weight: var(--ps-fw-normal); }

.ps-sidebar__tools {
  display: flex;
  align-items: center;
  gap: var(--ps-s-2);
  justify-content: space-between;
}

/* --- usage meter -------------------------------------------------------- */
.ps-usage {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-2);
  padding: var(--ps-s-3);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-surface-1);
}
.ps-usage__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--ps-s-2);
}
.ps-usage__label {
  font-size: var(--ps-fs-2xs);
  font-weight: var(--ps-fw-semibold);
  letter-spacing: var(--ps-ls-label);
  text-transform: uppercase;
  color: var(--ps-text-3);
}
.ps-usage__value {
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-semibold);
  font-variant-numeric: tabular-nums;
  color: var(--ps-text-1);
}
.ps-usage__track {
  block-size: var(--ps-s-2);
  border-radius: var(--ps-r-pill);
  background-color: var(--ps-surface-3);
  overflow: hidden;
}
.ps-usage__fill {
  display: block;
  block-size: 100%;
  min-inline-size: var(--ps-s-1);
  border-radius: var(--ps-r-pill);
  background-image: var(--ps-grad-brand);
  transition: inline-size var(--ps-t-slow);
}
.ps-usage__note { font-size: var(--ps-fs-xs); color: var(--ps-text-3); }
.ps-usage__note a { color: var(--ps-accent-text); font-weight: var(--ps-fw-medium); }

/* --- plan pill ---------------------------------------------------------- */
.ps-tier {
  display: inline-flex;
  align-items: center;
  gap: var(--ps-s-1);
  padding: var(--ps-s-1) var(--ps-s-3);
  border: var(--ps-bw) solid var(--ps-border-strong);
  border-radius: var(--ps-r-pill);
  background-color: var(--ps-surface-2);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-xs);
  font-weight: var(--ps-fw-semibold);
  white-space: nowrap;
}
.ps-tier[data-tier="pro"] {
  border-color: var(--ps-warm-edge);
  background-color: var(--ps-warm-tint);
  color: var(--ps-warm-text);
}
.ps-tier[data-tier="basic"] {
  border-color: var(--ps-accent-edge);
  background-color: var(--ps-accent-tint);
  color: var(--ps-accent-text);
}

/* --- off-canvas below the docked breakpoint ----------------------------- */
@media (max-width: 1023.98px) {
  .ps-sidebar__panel {
    position: fixed;
    inset-block: 0;
    inset-inline-start: 0;
    z-index: var(--ps-z-modal);
    inline-size: min(calc(var(--ps-sidebar-w) + var(--ps-s-6)), 88%);
    max-inline-size: 100%;
    border-radius: 0 var(--ps-r-card) var(--ps-r-card) 0;
    padding: max(var(--ps-s-5), env(safe-area-inset-top)) var(--ps-s-4) max(var(--ps-s-5), env(safe-area-inset-bottom));
    overflow-y: auto;
    overscroll-behavior: contain;
    box-shadow: var(--ps-e-4);
    transform: translateX(-100%);
    /* visibility, not just transform: an off-canvas panel that is merely moved
       stays in the tab order and in the accessibility tree (v1 kept four
       invisible links focusable at 390px). Transitioned so it stays visible
       for the duration of the slide-out. */
    visibility: hidden;
    transition:
      transform var(--ps-t-modal),
      visibility var(--ps-dur-4) var(--ps-ease-out);
  }
  .ps-sidebar__toggle[aria-expanded="true"] ~ .ps-sidebar__panel {
    transform: translateX(0);
    visibility: visible;
  }
  /* Wins over the reset's [hidden] on specificity, not with !important. */
  .ps-sidebar__toggle[aria-expanded="true"] ~ .ps-sidebar__backdrop {
    display: block;
    position: fixed;
    inset: 0;
    z-index: var(--ps-z-overlay);
    inline-size: 100%;
    background-color: var(--ps-scrim);
    -webkit-backdrop-filter: blur(var(--ps-glass-blur));
          backdrop-filter: blur(var(--ps-glass-blur));
    animation: ps-fade-in var(--ps-t-base) both;
  }
}

@media (min-width: 1024px) {
  .ps-sidebar {
    position: sticky;
    /* Derived, never hardcoded. */
    top: calc(var(--ps-nav-h) + var(--ps-s-5));
    align-self: start;
    z-index: var(--ps-z-sidebar);
    max-height: calc(100dvh - var(--ps-nav-h) - var(--ps-s-9));
    padding-block: var(--ps-s-8) var(--ps-s-4);
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: thin;
  }
  .ps-sidebar__panel { min-height: 100%; }
  .ps-sidebar__toggle,
  .ps-sidebar__close,
  .ps-sidebar__backdrop { display: none; }
}


/* ==========================================================================
   5. BUTTONS, ICON BUTTONS, CHIPS, BADGES, TAGS
   Every control here clears 44x44 (SPEC §9). v1 measured 38px category chips,
   a 40x40 menu button and a 101x19 upgrade link.
   ========================================================================== */
.ps-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--ps-s-2);
  min-height: var(--ps-tap-min);
  padding: var(--ps-s-2) var(--ps-s-5);
  border: var(--ps-bw) solid var(--ps-border-strong);
  border-radius: var(--ps-r-control);
  background-color: var(--ps-surface-2);
  color: var(--ps-text-1);
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-semibold);
  line-height: var(--ps-lh-normal);
  text-decoration: none;
  white-space: nowrap;
  transition:
    background-color var(--ps-t-base),
    border-color var(--ps-t-base),
    color var(--ps-t-base),
    box-shadow var(--ps-t-base),
    transform var(--ps-t-fast);
}
.ps-btn > .ps-icon { flex: none; }
.ps-btn:hover { background-color: var(--ps-surface-2-hover); color: var(--ps-text-1); }
.ps-btn:active { background-color: var(--ps-surface-2-active); transform: translateY(var(--ps-s-h)); }
.ps-btn:disabled,
.ps-btn[aria-disabled="true"] {
  background-color: var(--ps-surface-2);
  border-color: var(--ps-border);
  color: var(--ps-text-disabled);
  box-shadow: none;
  transform: none;
}

.ps-btn--primary {
  border-color: transparent;
  background-color: var(--ps-accent);
  background-image: var(--ps-grad-brand);
  color: var(--ps-on-accent);
  box-shadow: var(--ps-e-1);
}
.ps-btn--primary:hover {
  background-color: var(--ps-accent-hover);
  background-image: none;
  color: var(--ps-on-accent);
  box-shadow: var(--ps-e-glow);
}
.ps-btn--primary:active { background-color: var(--ps-accent-active); background-image: none; }
.ps-btn--primary:disabled,
.ps-btn--primary[disabled] {
  background-color: var(--ps-surface-3);
  background-image: none;
  color: var(--ps-text-disabled);
  box-shadow: none;
}

.ps-btn--quiet {
  border-color: transparent;
  background-color: transparent;
  color: var(--ps-text-2);
  font-weight: var(--ps-fw-medium);
}
.ps-btn--quiet:hover { background-color: var(--ps-surface-2); color: var(--ps-text-1); }

.ps-btn--danger-quiet {
  border-color: transparent;
  background-color: transparent;
  color: var(--ps-error-text);
}
.ps-btn--danger-quiet:hover {
  background-color: var(--ps-error-tint);
  border-color: var(--ps-error-edge);
  color: var(--ps-error-text);
}

.ps-btn--wide { inline-size: 100%; max-inline-size: var(--ps-composer-max); }

/* --- .ps-btn--sm: small PAINT, full-size TARGET -------------------------
   js/render.js and js/chat.js emit this for the per-message actions (Copy,
   Regenerate, Retry), the code-block Copy and the gated "See plans" — dense rows
   where a 44px-tall filled button is the wrong visual weight and, stacked three
   across under every bubble, makes the transcript look like a toolbar.

   So the box is 32px and the HIT AREA is still 44: an invisible ::after
   overhangs 6px above and below. The buttons in these rows sit side by side, so
   a vertical overhang cannot overlap its neighbours, and nothing above or below
   the row is interactive. Painted small, tappable large — rather than choosing
   one and losing the other. */
.ps-btn--sm {
  position: relative;
  gap: var(--ps-s-1);
  min-height: var(--ps-s-7);
  padding: var(--ps-s-1) var(--ps-s-3);
  font-size: var(--ps-fs-xs);
}
.ps-btn--sm::after {
  content: "";
  position: absolute;
  inset-block: calc((var(--ps-tap-min) - var(--ps-s-7)) / -2);
  inset-inline: 0;
}

/* Destructive confirmation (js/modal.js confirm({danger:true})). The hover step
   is --ps-error-text, which is the lighter red on dark and the darker red on
   light — so the state change is visible in both themes and --ps-on-error still
   clears AA on it. */
.ps-btn--danger {
  border-color: transparent;
  background-color: var(--ps-error);
  color: var(--ps-on-error);
  box-shadow: var(--ps-e-1);
}
.ps-btn--danger:hover { background-color: var(--ps-error-text); color: var(--ps-on-error); }
.ps-btn--danger:active { background-color: var(--ps-error); }

/* --- icon-only ---------------------------------------------------------- */
.ps-iconbtn {
  display: inline-grid;
  place-items: center;
  inline-size: var(--ps-tap-min);
  block-size: var(--ps-tap-min);
  border: var(--ps-bw) solid transparent;
  border-radius: var(--ps-r-2);
  color: var(--ps-text-2);
  transition: background-color var(--ps-t-fast), color var(--ps-t-fast), border-color var(--ps-t-fast);
}
.ps-iconbtn:hover { background-color: var(--ps-surface-2); color: var(--ps-text-1); }
.ps-iconbtn:active { background-color: var(--ps-surface-2-active); }

/* --- chips: category filters, conversation starters, dialog tabs -------- */
.ps-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--ps-s-2);
  min-height: var(--ps-tap-min);
  padding: var(--ps-s-2) var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-border-strong);
  border-radius: var(--ps-r-chip);
  background-color: var(--ps-surface-1);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-medium);
  text-align: start;
  transition:
    background-color var(--ps-t-base),
    border-color var(--ps-t-base),
    color var(--ps-t-base);
}
.ps-chip:hover { background-color: var(--ps-surface-1-hover); color: var(--ps-text-1); }
.ps-chip:active { background-color: var(--ps-surface-1-active); }

/* Selected state carries a border weight AND a fill AND the aria-pressed
   attribute it is derived from — never colour alone (SPEC §9). */
.ps-chip[aria-pressed="true"] {
  border-width: var(--ps-bw-strong);
  border-color: var(--ps-accent);
  background-color: var(--ps-accent-tint);
  color: var(--ps-accent-text);
  font-weight: var(--ps-fw-semibold);
}
.ps-chip__count {
  padding-inline: var(--ps-s-2);
  border-radius: var(--ps-r-pill);
  background-color: var(--ps-surface-3);
  color: var(--ps-text-3);
  font-size: var(--ps-fs-xs);
  font-variant-numeric: tabular-nums;
}
.ps-chip[aria-pressed="true"] .ps-chip__count {
  background-color: var(--ps-accent);
  color: var(--ps-on-accent);
}

/* --- badges: labels, never controls ------------------------------------- */
.ps-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--ps-s-1);
  padding: var(--ps-s-h) var(--ps-s-2);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-pill);
  background-color: var(--ps-surface-2);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-xs);
  font-weight: var(--ps-fw-medium);
  line-height: var(--ps-lh-normal);
  white-space: nowrap;
}
.ps-badge > .ps-icon { flex: none; }

.ps-badge--beta {
  border-color: var(--ps-accent-edge);
  background-color: var(--ps-accent-tint);
  color: var(--ps-accent-text);
  font-size: var(--ps-fs-2xs);
  font-weight: var(--ps-fw-semibold);
  letter-spacing: var(--ps-ls-label);
  text-transform: uppercase;
}
.ps-badge--count {
  border-color: transparent;
  background-color: var(--ps-accent);
  color: var(--ps-on-accent);
  font-weight: var(--ps-fw-semibold);
  font-variant-numeric: tabular-nums;
}
.ps-badge--tier {
  border-color: var(--ps-warm-edge);
  background-color: var(--ps-warm-tint);
  color: var(--ps-warm-text);
  font-weight: var(--ps-fw-semibold);
  letter-spacing: var(--ps-ls-label);
}
.ps-badge--private {
  border-color: var(--ps-info-edge);
  background-color: var(--ps-info-tint);
  color: var(--ps-info-text);
}
.ps-badge--status[data-status="approved"] {
  border-color: var(--ps-success-edge);
  background-color: var(--ps-success-tint);
  color: var(--ps-success-text);
}
.ps-badge--status[data-status="rejected"] {
  border-color: var(--ps-error-edge);
  background-color: var(--ps-error-tint);
  color: var(--ps-error-text);
}
.ps-badge--status[data-status="pending"] {
  border-color: var(--ps-warning-edge);
  background-color: var(--ps-warning-tint);
  color: var(--ps-warning-text);
}

/* --- tags: 13px floor. v1 rendered these at 11.2px AND 3.78:1 — a compound
       failure on the most repeated element on the page. ------------------- */
.ps-taglist {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ps-s-1);
}
.ps-tag {
  padding: var(--ps-s-h) var(--ps-s-2);
  border-radius: var(--ps-r-1);
  background-color: var(--ps-surface-2);
  color: var(--ps-text-3);
  font-size: var(--ps-fs-xs);
  line-height: var(--ps-lh-normal);
}
.ps-taglist--lg { gap: var(--ps-s-2); }
.ps-taglist--lg .ps-tag {
  padding: var(--ps-s-1) var(--ps-s-3);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-pill);
  font-size: var(--ps-fs-sm);
  color: var(--ps-text-2);
}

/* --- notices + upsell --------------------------------------------------- */
.ps-notice {
  display: flex;
  align-items: flex-start;
  gap: var(--ps-s-3);
  padding: var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-info-edge);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-info-tint);
  color: var(--ps-text-1);
  font-size: var(--ps-fs-sm);
  line-height: var(--ps-lh-relaxed);
}
.ps-notice > .ps-icon { flex: none; color: var(--ps-info-text); margin-block-start: var(--ps-s-h); }
.ps-notice--error {
  border-color: var(--ps-error-edge);
  background-color: var(--ps-error-tint);
}
.ps-notice--error > .ps-icon { color: var(--ps-error-text); }
.ps-notice--wide { grid-column: 1 / -1; }

.ps-upsell {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--ps-s-2);
  padding: var(--ps-s-3) var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-warm-edge);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-warm-tint);
  color: var(--ps-text-1);
  font-size: var(--ps-fs-sm);
}
.ps-upsell > .ps-icon { color: var(--ps-warm-text); }
.ps-upsell a { color: var(--ps-warm-text); font-weight: var(--ps-fw-semibold); }


/* ==========================================================================
   6. DISCOVER: heading, filters, grid, card
   ========================================================================== */
.ps-discover {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-6);
}

.ps-discover__title {
  font-size: var(--ps-fs-3xl);
  font-weight: var(--ps-fw-bold);
}
h1.ps-discover__title {
  font-size: var(--ps-fs-hero);
  font-weight: var(--ps-fw-extrabold);
  line-height: var(--ps-lh-tight);
  letter-spacing: var(--ps-ls-display);
}
.ps-discover__lead {
  max-inline-size: var(--ps-prose-max);
  margin-block-start: calc(var(--ps-s-3) * -1);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-lg);
  line-height: var(--ps-lh-relaxed);
  text-wrap: pretty;
}

/* --- filter bar --------------------------------------------------------- */
.ps-filters {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-4);
  padding: var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-card);
  background-color: var(--ps-surface-1);
  box-shadow: var(--ps-e-1);
}
.ps-filters__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ps-s-3);
}
.ps-filters__submit { flex: none; }

.ps-chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ps-s-2);
}

/* --- phone/tablet: the fold matters more than the furniture -------------
   Measured at 390x844 before this block: the H1, a three-line lead and the
   filter panel filled the entire first viewport and the category chips wrapped
   to FIVE rows, so the first persona card started at y=1204 — the user had to
   scroll 1.4 viewports to see one card on a page whose whole job is to show
   personas. Nothing here touches the desktop layout: the two rules the desktop
   shares are in the base, and this block only compresses.

   The four moves, in order of how much they bought:
     1. the category chips become ONE horizontally scrollable row (~200px saved)
     2. the lead drops to body size (--ps-fs-lg is a desktop luxury) (~30px)
     3. the section rhythm halves: --ps-s-9 between sections is right for a
        1440px page and absurd on a 390px one (~50px)
     4. the filter panel tightens by one spacing step throughout (~20px) */
@media (max-width: 767.98px) {
  .ps-main {
    padding-block: var(--ps-s-3) var(--ps-s-9);
    gap: var(--ps-s-7);
  }
  .ps-sidebar__toggle { margin-block-start: var(--ps-s-3); }
  .ps-discover { gap: var(--ps-s-4); }
  .ps-discover__lead {
    margin-block-start: calc(var(--ps-s-2) * -1);
    font-size: var(--ps-fs-md);
    line-height: var(--ps-lh-normal);
  }
  .ps-filters { gap: var(--ps-s-3); padding: var(--ps-s-3); }
  /* An explicit two-column grid rather than flex wrapping. Wrapping put the
     submit on a line of its own under the Pro pill, so the button was orphaned
     from the field it submits; and because an <input> has an intrinsic minimum
     width, which line it landed on changed with the placeholder text. Every
     placement here is stated, so nothing depends on intrinsic sizing:
       row 1  search field | Search
       row 2  sort (full width — a squeezed <select> truncates its own option)
       row 3  Pro-only pill (full width: a 326px-wide tap target)
       row 4  Clear, when there is anything to clear */
  .ps-filters__row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    gap: var(--ps-s-2);
    align-items: center;
  }
  .ps-field--search { grid-column: 1; grid-row: 1; }
  .ps-filters__submit { grid-column: 2; grid-row: 1; }
  .ps-field--sort { grid-column: 1 / -1; grid-row: 2; }
  .ps-field--sort select { flex: 1 1 auto; inline-size: 100%; }
  .ps-filters .ps-field--check { grid-column: 1 / -1; grid-row: 3; }
  .ps-filters__row > .ps-btn--quiet { grid-column: 1 / -1; grid-row: 4; justify-self: start; }
  .ps-about { gap: var(--ps-s-5); padding: var(--ps-s-5); }
}

/* Tablet: everything fits on one filter row, but only if the search field is
   allowed to give up its --ps-card-min basis. Left at 260px the four controls
   measured 724px against 696px of panel at exactly 768px, so the Search button
   wrapped to a line of its own — an orphan under the Pro pill. */
@media (min-width: 768px) and (max-width: 1023.98px) {
  /* Scoped to .ps-filters, and not because it needs to be: `.ps-field--search`
     alone is (0,1,0), the same weight as the base rule in §10 which comes LATER
     in the file, so it would silently lose. Measured 297px against a basis of
     260px before this was fixed. */
  .ps-filters .ps-field--search { flex: 1 1 var(--ps-s-11); }
}

/* One scrolling row of chips from the tablet breakpoint down. Below 1024 the
   sidebar is a drawer, so the main column is full-bleed and the category list is
   the widest thing on the page; wrapped, it was five rows at 390px and three at
   768px. The fade is the mask token (no colour of its own, so it works over both
   themes and the aurora) and it is LIFTED on focus-within, because a focus ring
   on the last chip must not be masked away.

   TWO measured problems with the first version of this, both of which made a
   scrollable row read as a broken one:

   1. The falloff was --ps-s-7 (32px) against chips of ~150px, so at 390px the
      third chip was cut through the middle of "Dev" with the rest of the pill
      still fully opaque — a hard vertical slice across a rounded border, which is
      the signature of clipping, not of a fade. --ps-fade-w is raised to 48px
      here (tokens.css §1.15 is parameterised for exactly this), which dissolves
      a third of a chip and cannot be mistaken for a cut.

   2. The fade was unconditional, so the LAST chip stayed permanently dimmed even
      once the user had scrolled to the very end — the row went on promising more
      when there was no more, which is the same lie the clipped chip told.
      `padding-inline-end` on the scroller is the fix: it extends the scrollable
      area by the falloff distance, so at maximum scroll the mask is dissolving
      48px of EMPTY PADDING and the last chip sits at full opacity. It also means
      that when the categories DO fit (a small install, or 768px with few
      categories) the fade lands on padding rather than on a chip, so nothing is
      dimmed for no reason. No script, no scroll listener, no animation. */
@media (max-width: 1023.98px) {
  .ps-chips {
    --ps-fade-w: var(--ps-s-9);
    flex-wrap: nowrap;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x proximity;
    /* A thin scrollbar is itself an affordance on any pointer device that draws
       one; the padding-block-end below keeps it off the chips' borders. */
    scrollbar-width: thin;
    padding-block-end: var(--ps-s-1);
    /* The run-off room described above. Also the snap stop, so a chip snapped to
       the end edge is never left underneath the dissolve. */
    padding-inline-end: var(--ps-fade-w);
    scroll-padding-inline-end: var(--ps-fade-w);
  }
  .ps-chips > .ps-chip { flex: none; scroll-snap-align: start; }
  .ps-chips:not(:focus-within) {
    -webkit-mask-image: var(--ps-fade-r);
            mask-image: var(--ps-fade-r);
  }
}

/* --- the grid ----------------------------------------------------------- */
.ps-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(var(--ps-card-min), 1fr));
  gap: var(--ps-s-6);
}
.ps-grid__cell { display: flex; min-inline-size: 0; }

/* --- the card ----------------------------------------------------------- */
.ps-card {
  position: relative;
  display: flex;
  flex-direction: column;
  inline-size: 100%;
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-card);
  background-color: var(--ps-surface-1);
  box-shadow: var(--ps-e-1);
  overflow: hidden;
  /* Hover changes elevation and lift ONLY. v1's hover un-clamped the
     description and grew the card from 462px to ~810px, bursting the row. */
  transition:
    transform var(--ps-t-base),
    box-shadow var(--ps-t-base),
    border-color var(--ps-t-base);
}
.ps-card:hover {
  transform: translateY(calc(var(--ps-s-1) * -1));
  border-color: var(--ps-accent-edge);
  box-shadow: var(--ps-e-3);
}

.ps-card__media {
  position: relative;
  aspect-ratio: 4 / 3;
  background-color: var(--ps-surface-2);
  overflow: hidden;
}
.ps-card__media > .ps-avatar {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  transition: transform var(--ps-dur-4) var(--ps-ease-out);
}
.ps-card:hover .ps-card__media > .ps-avatar { transform: scale(1.05); }

/* The scrim the badges sit on. Identical in both themes and dark by
   definition, which is what makes white-on-scrim provably legible over an
   unknown photo. */
.ps-card__media::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: var(--ps-scrim-image);
  pointer-events: none;
}

.ps-card__badges {
  position: absolute;
  inset-inline: var(--ps-s-2);
  inset-block-end: var(--ps-s-2);
  z-index: var(--ps-z-raised);
  display: flex;
  flex-wrap: wrap;
  gap: var(--ps-s-1);
}
.ps-card__badges .ps-badge {
  border-color: var(--ps-glass-hairline);
  background-color: var(--ps-scrim);
  color: var(--ps-on-scrim);
  -webkit-backdrop-filter: blur(var(--ps-glass-blur));
          backdrop-filter: blur(var(--ps-glass-blur));
}
.ps-card__badges .ps-badge--tier {
  border-color: var(--ps-warm);
  color: var(--ps-warm);
}

.ps-card__body {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-2);
  flex: 1 1 auto;
  padding: var(--ps-s-4);
}
/* Two lines, reserved, clamped — the same contract as the tagline below.
   Rendered without it, "Captain Aderyn Brass, the Clockwork Cartographer" and
   "MegaProofreader & SeparateParagraphBot" took two lines while their neighbours
   took one, and since grid cells stretch, the other three cards in each of those
   rows carried ~50px of dead space. The name is the one thing on a card that
   cannot be shortened by the CSS, so it gets a fixed box instead. */
.ps-card__name {
  font-size: var(--ps-fs-lg);
  font-weight: var(--ps-fw-semibold);
  line-height: var(--ps-lh-snug);
  letter-spacing: var(--ps-ls-normal);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
          line-clamp: 2;
  overflow: hidden;
}
.ps-card__link {
  color: var(--ps-text-1);
  text-decoration: none;
  transition: color var(--ps-t-fast);
}
.ps-card:hover .ps-card__link { color: var(--ps-accent-text); }
/* THE one focusable control per card, stretched over the whole card: one tab
   stop, Enter and Space both work, and it still navigates with JS off. */
.ps-card__link::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: var(--ps-z-raised);
  border-radius: var(--ps-r-card);
}
.ps-card__link:focus-visible { outline: none; }
.ps-card__link:focus-visible::after {
  /* Inset, because the card is overflow:hidden and an outward ring would be
     clipped by its own parent. */
  outline: var(--ps-focus-outline);
  outline-offset: calc(var(--ps-focus-offset) * -1);
}
.ps-card__tagline {
  color: var(--ps-text-2);
  font-size: var(--ps-fs-sm);
  line-height: var(--ps-lh-normal);
  /* Exactly two lines, reserved whether or not the text fills them, so a row
     of cards cannot go ragged. v1 measured 462/471/423px in one row, with a
     ~95px empty hole in the short card. */
  min-block-size: calc(var(--ps-fs-sm) * var(--ps-lh-normal) * 2);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
          line-clamp: 2;
  overflow: hidden;
}

/* Tags are pinned to the foot of the card and WRAP inside it.
   They used to be one nowrap row dissolved by --ps-fade-r, on the theory that a
   single row keeps a grid row even. Rendered, that was wrong twice: the fade is
   32px against tag chips that are ~100px, so the second chip on the "6 Hat" card
   was chopped through the middle of a word and read as a clipping bug rather than
   as a fade, and the third tag vanished with nothing to say it existed.

   Wrapping costs nothing here: the cells are grid items, so every card in a row
   is already stretched to the tallest card in that row (--ps-grid has the default
   `align-items: stretch`), and `margin-block-start: auto` sends the slack above
   the tag row rather than leaving a hole under the last line of the tagline. So a
   two-line tag row makes the row a little taller and keeps every card in it
   identical — which is what the constraint was actually protecting.

   A single tag longer than the card still cannot overflow: each chip is capped at
   the content box and ellipsised, so the failure mode is a readable truncation
   inside the card, not a chip sticking out of it. */
.ps-card .ps-taglist {
  flex-wrap: wrap;
  padding-block-start: var(--ps-s-1);
  row-gap: var(--ps-s-1);
}
.ps-card .ps-tag {
  flex: 0 1 auto;
  max-inline-size: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ps-avatar--fallback {
  display: grid;
  place-items: center;
  inline-size: 100%;
  block-size: 100%;
  background-image: var(--ps-grad-brand);
  color: var(--ps-on-accent);
}

.ps-pagination {
  display: flex;
  justify-content: center;
  padding-block-start: var(--ps-s-2);
}


/* ==========================================================================
   7. PERSONA DETAIL + ABOUT
   ?persona=<id>-<slug> renders a server-side summary above the grid, so every
   persona is crawlable and readable before any JS runs.
   ========================================================================== */
.ps-breadcrumb__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ps-s-2);
  font-size: var(--ps-fs-sm);
  color: var(--ps-text-3);
}
.ps-breadcrumb__list li + li::before {
  content: "/";
  margin-inline-end: var(--ps-s-2);
  color: var(--ps-border-strong);
}
.ps-breadcrumb__list a { color: var(--ps-text-2); }
.ps-breadcrumb__list [aria-current="page"] { color: var(--ps-text-1); font-weight: var(--ps-fw-medium); }

.ps-persona {
  display: grid;
  gap: var(--ps-s-6);
  padding: var(--ps-s-5);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-card);
  background-color: var(--ps-surface-1);
  box-shadow: var(--ps-e-2);
}
.ps-persona__media { position: relative; }
.ps-persona__media > .ps-avatar {
  inline-size: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: var(--ps-r-4);
  background-color: var(--ps-surface-2);
}
.ps-persona__media > .ps-avatar--fallback { block-size: auto; }

.ps-persona__body {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-4);
  min-inline-size: 0;
}
.ps-persona__name {
  font-size: var(--ps-fs-3xl);
  font-weight: var(--ps-fw-bold);
  letter-spacing: var(--ps-ls-tight);
}
.ps-persona__tagline {
  margin-block-start: calc(var(--ps-s-3) * -1);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-lg);
  line-height: var(--ps-lh-snug);
}
.ps-persona__meta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ps-s-2) var(--ps-s-5);
  font-size: var(--ps-fs-sm);
  color: var(--ps-text-3);
}
.ps-persona__meta > li { display: inline-flex; align-items: center; gap: var(--ps-s-2); }
.ps-persona__meta a { color: var(--ps-text-2); }
.ps-persona__gate { color: var(--ps-warm-text); }
.ps-persona__desc {
  max-inline-size: var(--ps-prose-max);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-md);
  line-height: var(--ps-lh-relaxed);
  text-wrap: pretty;
}
.ps-persona__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ps-s-3);
  padding-block-start: var(--ps-s-1);
}

.ps-starters { display: flex; flex-direction: column; gap: var(--ps-s-3); }
.ps-starters__title {
  font-size: var(--ps-fs-2xs);
  font-weight: var(--ps-fw-semibold);
  letter-spacing: var(--ps-ls-label);
  text-transform: uppercase;
  color: var(--ps-text-3);
}
.ps-starters__list { display: flex; flex-wrap: wrap; gap: var(--ps-s-2); }
.ps-starters__list .ps-chip { font-weight: var(--ps-fw-normal); }

@media (min-width: 768px) {
  .ps-persona {
    grid-template-columns: minmax(0, var(--ps-card-min)) minmax(0, 1fr);
    align-items: start;
    padding: var(--ps-s-6);
    gap: var(--ps-s-7);
  }
  .ps-persona__name { font-size: var(--ps-fs-4xl); line-height: var(--ps-lh-tight); letter-spacing: var(--ps-ls-display); }
}

/* --- about + stats ------------------------------------------------------- */
.ps-about {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-6);
  padding: var(--ps-s-6);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-card);
  background-color: var(--ps-surface-1);
}
.ps-about__title { font-size: var(--ps-fs-2xl); }
.ps-about__prose {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-4);
  /* v1 centred this prose at a ~105-character measure. */
  max-inline-size: var(--ps-prose-max);
  color: var(--ps-text-2);
  line-height: var(--ps-lh-relaxed);
  text-wrap: pretty;
}
.ps-about__cta { display: flex; }

.ps-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(var(--ps-card-min), 1fr));
  gap: var(--ps-s-3);
}
.ps-stats__item {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-1);
  padding: var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-surface-2);
}
.ps-stats__item dt {
  font-size: var(--ps-fs-2xs);
  font-weight: var(--ps-fw-semibold);
  letter-spacing: var(--ps-ls-label);
  text-transform: uppercase;
  color: var(--ps-text-3);
}
/* v1's stat numerals were --accent as text at 2.91:1. --ps-accent-text is the
   lighter step that exists precisely so a number can be brand-coloured and
   still clear 4.5:1. */
.ps-stats__item dd {
  color: var(--ps-accent-text);
  font-size: var(--ps-fs-3xl);
  font-weight: var(--ps-fw-bold);
  line-height: var(--ps-lh-flat);
  letter-spacing: var(--ps-ls-tight);
  font-variant-numeric: tabular-nums;
}
.ps-stats--inline { grid-template-columns: repeat(auto-fit, minmax(calc(var(--ps-card-min) / 2), 1fr)); }
.ps-stats--inline dd { font-size: var(--ps-fs-2xl); }


/* ==========================================================================
   8. DIALOGS
   `hidden` is the closed state for all nine (partials/chat.php). Nothing here
   depends on an is-open class, so a dialog is correct the moment modal.js
   removes the attribute — and stays correct if it also adds a class.
   ========================================================================== */
.ps-dialog {
  position: fixed;
  inset: 0;
  z-index: var(--ps-z-modal);
  padding: var(--ps-s-4);
  background-color: var(--ps-scrim);
  -webkit-backdrop-filter: blur(var(--ps-glass-blur));
          backdrop-filter: blur(var(--ps-glass-blur));
  overscroll-behavior: contain;
}
/* :not([hidden]) rather than a display override, so the reset's [hidden] rule
   is never in a specificity fight and no !important is needed. */
.ps-dialog:not([hidden]) {
  display: flex;
  align-items: center;
  justify-content: center;
  animation: ps-fade-in var(--ps-t-base) both;
}

.ps-dialog__panel {
  display: flex;
  flex-direction: column;
  inline-size: 100%;
  max-inline-size: var(--ps-dialog-w);
  max-block-size: 100%;
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-modal);
  background-color: var(--ps-bg);
  box-shadow: var(--ps-e-4);
  overflow: hidden;
  animation: ps-dialog-in var(--ps-t-modal) both;
}
.ps-dialog--form .ps-dialog__panel,
.ps-dialog--chat .ps-dialog__panel { max-inline-size: var(--ps-dialog-w-lg); }
.ps-dialog--consent .ps-dialog__panel { max-inline-size: var(--ps-prose-max); }
.ps-dialog--chat .ps-dialog__panel { block-size: 100%; }
/* js/modal.js confirm(): one question and two buttons. At --ps-dialog-w it was a
   720px-wide panel holding a nine-word sentence. */
.ps-dialog--confirm .ps-dialog__panel { max-inline-size: var(--ps-dialog-w-sm); }
.ps-dialog--confirm .ps-dialog__body { color: var(--ps-text-1); }

/* modal.js puts this on <html> for as long as any dialog is open. It was never
   styled, so the page scrolled behind every open dialog — including the chat
   sheet, where a touch scroll on the phone moved the page rather than the
   transcript. `scrollbar-gutter: stable` is on <html> in §1 precisely so that
   taking the scrollbar away here does not shift the layout by 15px. */
.ps-scroll-lock { overflow: hidden; }

.ps-dialog__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ps-s-3);
  flex: none;
  padding: var(--ps-s-4) var(--ps-s-5);
  border-block-end: var(--ps-bw) solid var(--ps-border);
  background-color: var(--ps-surface-1);
}
.ps-dialog__title {
  display: flex;
  align-items: center;
  gap: var(--ps-s-2);
  min-inline-size: 0;
  font-size: var(--ps-fs-2xl);
  font-weight: var(--ps-fw-semibold);
}
.ps-dialog__title > .ps-icon { flex: none; color: var(--ps-accent-ui); }
.ps-dialog__headactions { display: flex; align-items: center; gap: var(--ps-s-2); flex: none; }
.ps-dialog__close { flex: none; }

.ps-dialog__body {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-4);
  flex: 1 1 auto;
  min-block-size: 0;
  padding: var(--ps-s-5);
  overflow-y: auto;
  overscroll-behavior: contain;
  color: var(--ps-text-2);
  line-height: var(--ps-lh-relaxed);
}
.ps-dialog__body > .ps-notice { color: var(--ps-text-1); }
.ps-dialog--consent .ps-dialog__body strong { color: var(--ps-text-1); }

.ps-dialog--consent .ps-form__actions,
.ps-dialog--chat .ps-chat__composer { flex: none; }

@keyframes ps-fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@keyframes ps-dialog-in {
  from { opacity: 0; transform: translateY(var(--ps-s-3)) scale(0.985); }
  to   { opacity: 1; transform: none; }
}

@media (min-width: 768px) {
  .ps-dialog { padding: var(--ps-s-8); }
}

/* Full-bleed sheet on a phone, with the insets v1 never had: its chat modal
   started at y=0 under the notch and put the composer under the home
   indicator. */
@media (max-width: 767.98px) {
  .ps-dialog--chat { padding: 0; }
  .ps-dialog--chat .ps-dialog__panel {
    max-inline-size: 100%;
    border: 0;
    border-radius: 0;
  }
  .ps-dialog--chat .ps-chat__head { padding-block-start: max(var(--ps-s-4), env(safe-area-inset-top)); }
}


/* ==========================================================================
   9. CHAT SURFACE
   Both a view and a dialog. The panel is a flex column and the transcript is
   the only thing that scrolls inside it — legitimate here, and NOT the same
   thing as giving the page shell a 100vh inner-scroll pane (§3).
   ========================================================================== */
.ps-chat__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ps-s-3);
  flex: none;
  padding: var(--ps-s-3) var(--ps-s-4);
  border-block-end: var(--ps-bw) solid var(--ps-border);
  background-color: var(--ps-surface-1);
}
.ps-chat__who {
  display: flex;
  align-items: center;
  gap: var(--ps-s-3);
  min-inline-size: 0;
}
.ps-chat__avatar {
  display: grid;
  place-items: center;
  flex: none;
  inline-size: var(--ps-s-8);
  block-size: var(--ps-s-8);
  border-radius: var(--ps-r-avatar);
  background-color: var(--ps-surface-3);
  color: var(--ps-accent-ui);
  overflow: hidden;
}
.ps-chat__avatar > img { inline-size: 100%; block-size: 100%; object-fit: cover; }
.ps-chat__ident { min-inline-size: 0; }
.ps-chat__name {
  font-size: var(--ps-fs-xl);
  font-weight: var(--ps-fw-semibold);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ps-chat__sub {
  color: var(--ps-text-3);
  font-size: var(--ps-fs-sm);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ps-chat__actions { display: flex; align-items: center; gap: var(--ps-s-1); flex: none; }

.ps-chat__log {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-4);
  flex: 1 1 auto;
  min-block-size: 0;
  padding: var(--ps-s-5) var(--ps-s-4);
  overflow-y: auto;
  overscroll-behavior: contain;
  scroll-behavior: smooth;
  background-color: var(--ps-bg);
}
/* Everything in the transcript shares one measure, centred, so the log can be
   as wide as the dialog without the text becoming unreadable. */
.ps-chat__log > * {
  inline-size: 100%;
  max-inline-size: var(--ps-chat-max);
  margin-inline: auto;
}
/* Scrollable region, so it is a tab stop; the ring is inset because an outward
   one would be clipped by the panel's overflow. */
.ps-chat__log:focus-visible { outline-offset: calc(var(--ps-focus-offset) * -1); }

/* --- messages (built by js/render.js; see DESIGN.md §8) ----------------- */
/* Avatars align to the TOP of the bubble, not the bottom. A long reply is
   400-700px tall in this layout, and with `align-items: flex-end` the
   assistant's face ended up level with the Copy/Regenerate row at the very
   bottom of the bubble — 600px away from the first word it belongs to, and
   reading as an accident. Every reference product (character.ai, ChatGPT,
   Claude) anchors identity where reading starts. The bubble's tail corner moves
   with it, below. */
.ps-msg {
  display: flex;
  align-items: flex-start;
  gap: var(--ps-s-2);
}
.ps-msg--user { flex-direction: row-reverse; }
.ps-msg__avatar {
  display: grid;
  place-items: center;
  flex: none;
  inline-size: var(--ps-s-7);
  block-size: var(--ps-s-7);
  border-radius: var(--ps-r-avatar);
  background-color: var(--ps-surface-3);
  color: var(--ps-accent-ui);
  overflow: hidden;
}
.ps-msg__avatar > img { inline-size: 100%; block-size: 100%; object-fit: cover; }
.ps-msg__bubble {
  max-inline-size: 84%;
  padding: var(--ps-s-3) var(--ps-s-4);
  border-radius: var(--ps-r-bubble);
  background-color: var(--ps-surface-2);
  color: var(--ps-text-1);
  font-size: var(--ps-fs-md);
  line-height: var(--ps-lh-relaxed);
  box-shadow: var(--ps-e-1);
  overflow-wrap: break-word;
}
.ps-msg__bubble > * + * { margin-block-start: var(--ps-s-3); }
/* One tight corner on the tail side is what makes a bubble read as spoken. The
   tail is on the START edge (top), pointing at the avatar, because the avatar is
   top-aligned; a bottom tail with a top avatar pointed at nothing. */
.ps-msg--assistant .ps-msg__bubble { border-start-start-radius: var(--ps-r-bubble-tail); }
.ps-msg--user .ps-msg__bubble {
  /* A flat fill, never the gradient: SPEC §8 forbids a gradient under text, and
     the light end of --ps-grad-brand is 3.2:1 against white. */
  background-color: var(--ps-accent);
  color: var(--ps-on-accent);
  border-start-end-radius: var(--ps-r-bubble-tail);
}
.ps-msg--user .ps-msg__bubble a { color: var(--ps-on-accent); }
.ps-msg__meta {
  display: flex;
  align-items: center;
  gap: var(--ps-s-2);
  margin-block-start: var(--ps-s-1);
  color: var(--ps-text-3);
  font-size: var(--ps-fs-xs);
}
/* State comes from `data-ps-state` on the article, which is what js/render.js
   actually writes (`.ps-msg--error` was styled here and emitted nowhere — a dead
   selector for a state the product really has). `failed` and `partial` both keep
   their text: a stopped or failed reply is still content, so it is marked, not
   hidden. The word "not sent" / "stopped" is written into .ps-msg__meta by
   render.js, so this is never colour alone. */
.ps-msg[data-ps-state="failed"] .ps-msg__bubble {
  border: var(--ps-bw) solid var(--ps-error-edge);
  background-color: var(--ps-error-tint);
}
.ps-msg[data-ps-state="partial"] .ps-msg__bubble {
  border: var(--ps-bw) dashed var(--ps-border-strong);
}
.ps-msg[data-ps-state="failed"] .ps-msg__meta,
.ps-msg[data-ps-state="partial"] .ps-msg__meta { color: var(--ps-error-text); }

/* The meta line inside a USER bubble sits on --ps-accent, not on a surface, so
   it cannot inherit --ps-text-3. Measured before this rule: `sending…` rendered
   #A79CC7 on #7C4DFF = **1.88:1**, an axe `color-contrast` SERIOUS on every
   message the user sends, for the whole duration of the send — the single worst
   contrast anywhere in the product.

   It resolves to --ps-on-accent (white): 4.81:1 on the dark accent and 6.63:1 on
   the light one. There is no room for a *muted* white here — white itself only
   clears the 4.5 bar by 0.31 on dark — so the de-emphasis is carried by size
   (--ps-fs-xs) rather than by colour, which is what iMessage and Messenger do
   with their own on-fill timestamps.

   Excluded from `failed`/`partial`, because those states repaint the bubble with
   --ps-error-tint and their own --ps-error-text meta is correct against it. The
   :not() pair is what keeps this (0,4,0) rule from silently outranking the
   (0,2,0) state rules above it. */
.ps-msg--user:not([data-ps-state="failed"]):not([data-ps-state="partial"]) .ps-msg__meta {
  color: var(--ps-on-accent);
}

/* --- the streaming caret ------------------------------------------------
   chat.js repaints the whole markdown buffer each frame and marks the article
   `data-ps-state="streaming"`; there was no visual for it at all, so a reply
   that had stopped mid-sentence looked identical to one that was still coming.
   The caret is a pseudo-element on the LAST block of the body, so it moves with
   the text and needs nothing from the JS. It blinks with the typing period, so
   tokens.css §6 stops it with everything else. */
.ps-msg[data-ps-state="streaming"] .ps-msg__bubble > [data-ps-body] > :last-child::after {
  content: "";
  display: inline-block;
  inline-size: var(--ps-s-h);
  block-size: var(--ps-s-4);
  margin-inline-start: var(--ps-s-1);
  border-radius: var(--ps-r-1);
  background-color: var(--ps-accent-text);
  vertical-align: text-bottom;
  animation: ps-caret var(--ps-dur-typing) var(--ps-ease-linear) infinite;
}
@keyframes ps-caret {
  0%, 45%   { opacity: 1; }
  55%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .ps-msg[data-ps-state="streaming"] .ps-msg__bubble > [data-ps-body] > :last-child::after {
    animation: none;
  }
}

/* --- markdown inside a bubble (js/markdown.js) --------------------------
   The renderer emits headings, lists, blockquotes, rules, tables and fenced code
   and NONE of it was styled: the reset had already stripped list markers
   globally, so a bulleted reply rendered as unindented run-on lines, and a
   fenced code block was body-copy text on the bubble fill. This is the
   product's core screen; markdown is most of what it renders.

   `[data-ps-body]` is the contract render.js documents, so these hang off that
   rather than off a class the JS does not emit. */
.ps-msg__bubble > [data-ps-body] > * + * { margin-block-start: var(--ps-s-3); }

.ps-msg__bubble h1,
.ps-msg__bubble h2,
.ps-msg__bubble h3,
.ps-msg__bubble h4,
.ps-msg__bubble h5,
.ps-msg__bubble h6 {
  color: var(--ps-text-1);
  font-weight: var(--ps-fw-semibold);
  line-height: var(--ps-lh-snug);
  letter-spacing: var(--ps-ls-tight);
}
/* The bubble's body is --ps-fs-md, so a heading at --ps-fs-md is distinguished
   by weight alone and reads as bold text, not as structure — measured: an "###"
   heading was indistinguishable from a **bold** line. Every level therefore
   steps at least once above the body. */
.ps-msg__bubble h1,
.ps-msg__bubble h2 { font-size: var(--ps-fs-xl); }
.ps-msg__bubble h3,
.ps-msg__bubble h4,
.ps-msg__bubble h5,
.ps-msg__bubble h6 { font-size: var(--ps-fs-lg); }
/* A heading that follows a paragraph needs more air above than below. */
.ps-msg__bubble > [data-ps-body] > * + :is(h1, h2, h3, h4, h5, h6) { margin-block-start: var(--ps-s-4); }

.ps-msg__bubble ul,
.ps-msg__bubble ol {
  padding-inline-start: var(--ps-s-5);
  list-style: disc;
}
.ps-msg__bubble ol { list-style: decimal; }
.ps-msg__bubble li + li { margin-block-start: var(--ps-s-1); }
.ps-msg__bubble li > ul,
.ps-msg__bubble li > ol { margin-block-start: var(--ps-s-1); }
.ps-msg__bubble li > ul { list-style: circle; }
.ps-msg__bubble li::marker { color: var(--ps-text-3); }

.ps-msg__bubble blockquote {
  padding-inline-start: var(--ps-s-3);
  border-inline-start: var(--ps-bw-strong) solid var(--ps-accent-edge);
  color: var(--ps-text-2);
}
/* --ps-border is the DECORATIVE hairline, deliberately around 1.5:1 (tokens.css
   §2), and on the assistant bubble's --ps-surface-2 fill it measured 1.19:1 — so
   a `---` the model wrote rendered as nothing at all, just a slightly larger gap.
   A thematic break is content, not decoration, so it takes --ps-border-strong
   (3:1+ on every surface) and the reader can see the section change. */
.ps-msg__bubble hr {
  margin-block: var(--ps-s-3);
  border-top-color: var(--ps-border-strong);
}

/* Inline code, on a bubble rather than on the page: --ps-surface-3 reads as a
   well on the assistant's --ps-surface-2 fill and still carries --ps-text-1. */
.ps-msg__bubble :not(pre) > code {
  padding: 0 var(--ps-s-1);
  border-radius: var(--ps-r-1);
  background-color: var(--ps-surface-3);
  color: var(--ps-text-1);
}
/* Inside the user's accent bubble there is no surface to sit on, so the code
   well is a translucent overlay of the bubble itself. */
.ps-msg--user .ps-msg__bubble :not(pre) > code {
  background-color: var(--ps-overlay-press);
  color: var(--ps-on-accent);
}
.ps-msg__bubble strong { font-weight: var(--ps-fw-semibold); }
.ps-msg__bubble a { color: var(--ps-accent-text); }

/* Fenced code. The bar is a real header row (language + Copy), and the whole
   thing is inside a rounded, clipped box so a long line can never widen the
   bubble — measured against a 300-character single-token line. */
.ps-md-codeblock {
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-2);
  background-color: var(--ps-sunken);
  overflow: hidden;
}
.ps-md-codebar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ps-s-2);
  padding-inline: var(--ps-s-3);
  border-block-end: var(--ps-bw) solid var(--ps-border);
  background-color: var(--ps-surface-2);
}
.ps-md-lang {
  color: var(--ps-text-3);
  font-family: var(--ps-font-mono);
  font-size: var(--ps-fs-2xs);
  letter-spacing: var(--ps-ls-label);
  text-transform: uppercase;
}
/* Pushed to the end when there is no language token to sit opposite. */
.ps-md-codebar > .ps-md-copy:only-child { margin-inline-start: auto; }
/* The pre WRAPS rather than scrolls, and that is an accessibility decision, not
   a taste one. `overflow-x: auto` made every fenced block a scrollable region
   with no focusable content inside it, which axe reports as
   `scrollable-region-focusable` (serious): a keyboard-only user could not reach
   the hidden right-hand end of a long line, because a <pre> is not a tab stop
   and this file cannot add `tabindex` to a node js/markdown.js builds. Measured
   1 serious violation per fenced block, 2 on the phone. `pre-wrap` keeps every
   space and newline the author wrote — indentation is preserved exactly — and
   `break-word` only ever breaks a token that could not fit on a line of its
   own. It also removes one nested scrollbar per code block. */
.ps-md-pre {
  padding: var(--ps-s-3);
  color: var(--ps-text-1);
  font-size: var(--ps-fs-sm);
  line-height: var(--ps-lh-normal);
  white-space: pre-wrap;
  word-break: break-word;
  /* No hanging indent here, tried and reverted: `text-indent` formats the first
     line of a BLOCK, and a <pre> is one block containing newlines, so a negative
     indent pulled only `function onResize(fn) {` left and left every real source
     line looking one level deeper than it is. */
}
.ps-md-pre code {
  padding: 0;
  background-color: transparent;
  font-size: inherit;
}

/* Tables. markdown.js sets overflow-x inline as a belt-and-braces guarantee, so
   the wrapper can still scroll if a table really cannot reflow; this adds the
   box around it. Same accessibility problem as the pre above, so the table is
   sized to reflow instead: `fixed` layout plus wrappable headers means the four
   and five column tables the model actually emits fit a 390px bubble without a
   scroll region. */
.ps-md-tablewrap {
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-2);
  overscroll-behavior-x: contain;
  scrollbar-width: thin;
}
.ps-md-table {
  inline-size: 100%;
  table-layout: fixed;
  font-size: var(--ps-fs-sm);
}
.ps-md-table th,
.ps-md-table td {
  padding: var(--ps-s-2) var(--ps-s-3);
  border-block-end: var(--ps-bw) solid var(--ps-border);
  text-align: start;
  vertical-align: top;
  word-break: break-word;
}
.ps-md-table th {
  background-color: var(--ps-surface-2);
  color: var(--ps-text-1);
  font-weight: var(--ps-fw-semibold);
}
.ps-md-table tbody tr:last-child > * { border-block-end: 0; }

/* --- the empty transcript ----------------------------------------------
   js/render.js chatIntro(): the first thing a user sees after choosing a
   persona, and it had no styles at all — an 80px avatar, a name and a
   description stacked flush left at body size. */
.ps-chat__intro {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--ps-s-2);
  padding-block: var(--ps-s-6) var(--ps-s-4);
  text-align: center;
}
.ps-chat__intro .ps-chat__avatar {
  inline-size: var(--ps-s-11);
  block-size: var(--ps-s-11);
  margin-block-end: var(--ps-s-1);
  box-shadow: var(--ps-e-2);
}
.ps-chat__intro-name {
  color: var(--ps-text-1);
  font-family: var(--ps-font-display);
  font-size: var(--ps-fs-2xl);
  font-weight: var(--ps-fw-semibold);
  letter-spacing: var(--ps-ls-tight);
}
.ps-chat__intro-tagline { color: var(--ps-text-2); font-size: var(--ps-fs-md); }
.ps-chat__intro-desc {
  max-inline-size: var(--ps-prose-max);
  color: var(--ps-text-3);
  font-size: var(--ps-fs-sm);
  line-height: var(--ps-lh-relaxed);
  text-wrap: pretty;
}

.ps-chat__loading {
  padding-block: var(--ps-s-9);
  color: var(--ps-text-3);
  font-size: var(--ps-fs-sm);
  text-align: center;
}

/* --- jump to latest -----------------------------------------------------
   chat.js inserts this as the transcript's next sibling and toggles [hidden].
   It floats over the foot of the transcript rather than being absolutely
   positioned against the panel, because the composer's height changes as the
   textarea grows and any fixed offset from the bottom would drift. The negative
   margin is what keeps it out of the layout: it overlaps the transcript's own
   bottom padding, so revealing it does not resize the scroller (which would
   fight the scroll position that revealed it). */
.ps-chat__jump:not([hidden]) {
  position: relative;
  z-index: var(--ps-z-raised);
  display: inline-flex;
  flex: none;
  align-self: center;
  /* It is a flex item between the transcript and the composer, pulled UP into
     the transcript so it reads as floating over the conversation. The bottom
     margin is the clearance from the transcript's edge: at --ps-s-1 (4px) the
     pill sat on the divider and touched the last bubble, which looked like a
     layout fault rather than an overlay. */
  margin-block: calc(var(--ps-s-9) * -1) var(--ps-s-3);
  border-color: var(--ps-border-strong);
  border-radius: var(--ps-r-pill);
  background-color: var(--ps-surface-2);
  color: var(--ps-text-1);
  box-shadow: var(--ps-e-3);
}
.ps-chat__jump:hover { background-color: var(--ps-surface-2-hover); }

/* --- the "thinking" indicator -------------------------------------------
   chat.js appends this as a DIRECT CHILD of the transcript, so `.ps-chat__log > *`
   gave it the shared measure — `inline-size: 100%`, `max-inline-size: 760px`,
   `margin-inline: auto`. Measured at 1440: a 760x32 fully TRANSPARENT block with
   three bare dots at x=356, while the assistant's avatar column starts at x=340
   and its bubble at x=380. So the one thing on screen during the wait for the
   first token was three unattached dots sitting in the avatar's slot, and when the
   reply landed they were replaced by an avatar and a filled bubble 40px to the
   right — a jump, on the exact beat where the product most needs to look composed.

   It now IS a bubble: the assistant's fill, the assistant's tail corner, and the
   assistant's left edge. Reproducing that edge is the whole trick, because the
   element has to stop being measure-wide (a 760px bubble around three dots is
   absurd) without losing its alignment to the measure:

     margin-inline-start = half the log's leftover slack   -> the measure's edge
                         + avatar + gap                    -> the bubble's edge

   max(0px, …) is what makes it correct on a phone, where the log is narrower than
   --ps-chat-max and the slack is negative. Verified equal to the real assistant
   bubble's left edge to the pixel at 1440, 768 and 390. */
.ps-typing {
  display: inline-flex;
  align-items: center;
  gap: var(--ps-s-1);
  padding: var(--ps-s-3) var(--ps-s-4);
}
.ps-chat__log > .ps-typing {
  inline-size: fit-content;
  max-inline-size: 100%;
  margin-inline:
    calc(max(0px, 100% - var(--ps-chat-max)) / 2 + var(--ps-s-7) + var(--ps-s-2))
    auto;
  border-radius: var(--ps-r-bubble);
  border-start-start-radius: var(--ps-r-bubble-tail);
  background-color: var(--ps-surface-2);
  box-shadow: var(--ps-e-1);
}
.ps-typing > span {
  inline-size: var(--ps-s-2);
  block-size: var(--ps-s-2);
  border-radius: var(--ps-r-circle);
  background-color: var(--ps-text-3);
  animation: ps-typing var(--ps-dur-typing) var(--ps-ease-in-out) infinite;
}
.ps-typing > span:nth-child(2) { animation-delay: calc(var(--ps-dur-typing) / 8); }
.ps-typing > span:nth-child(3) { animation-delay: calc(var(--ps-dur-typing) / 4); }
@keyframes ps-typing {
  0%, 60%, 100% { opacity: 0.35; transform: translateY(0); }
  30%           { opacity: 1;    transform: translateY(calc(var(--ps-s-1) * -1)); }
}
@media (prefers-reduced-motion: reduce) {
  .ps-typing > span { animation: none; opacity: 0.6; }
}

/* --- starters strip -----------------------------------------------------
   js/chat.js fills this with a "Try asking" caption AND a <ul> of chips, not a
   flat row of chips. Styled as one flex row, the caption became column one of
   the scroller and every chip queued behind it. So the strip is a column and the
   LIST is the thing that scrolls. */
.ps-chat__starters:not([hidden]) {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-2);
  flex: none;
  padding: var(--ps-s-3) var(--ps-s-4);
  border-block-start: var(--ps-bw) solid var(--ps-border);
  background-color: var(--ps-bg);
}
.ps-chat__starters-title {
  color: var(--ps-text-3);
  font-size: var(--ps-fs-2xs);
  font-weight: var(--ps-fw-semibold);
  letter-spacing: var(--ps-ls-label);
  text-transform: uppercase;
}
.ps-chat__starters-list {
  display: flex;
  gap: var(--ps-s-2);
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: thin;
  /* Room for the scrollbar so it never sits on a chip's border. */
  padding-block-end: var(--ps-s-1);
}
/* A mask, not a painted gradient — it removes pixels, so it has no colour of
   its own and cannot mismatch the surface under it (tokens.css §1.15). Lifted
   while anything inside has focus, so a focus ring is never clipped. */
.ps-chat__starters-list:not(:focus-within) {
  -webkit-mask-image: var(--ps-fade-r);
          mask-image: var(--ps-fade-r);
}
.ps-chat__starters .ps-chip {
  flex: none;
  /* One line each: inside a horizontal scroller a wrapping chip makes the whole
     strip two rows tall and the fade cuts words rather than chips. */
  white-space: nowrap;
  /* Suggestions, not filters: one step quieter than a category chip, so the
     strip does not read as a second filter bar under the transcript. */
  border-color: var(--ps-border);
  background-color: var(--ps-surface-2);
  font-weight: var(--ps-fw-normal);
}
.ps-chat__starters .ps-chip:hover {
  border-color: var(--ps-accent-edge);
  background-color: var(--ps-surface-2-hover);
}

/* --- composer ----------------------------------------------------------- */
.ps-chat__composer {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-2);
  flex: none;
  padding: var(--ps-s-3) var(--ps-s-4) max(var(--ps-s-3), env(safe-area-inset-bottom));
  border-block-start: var(--ps-bw) solid var(--ps-border);
  background-color: var(--ps-surface-1);
}
.ps-chat__status:not(:empty) {
  max-inline-size: var(--ps-composer-max);
  margin-inline: auto;
  inline-size: 100%;
  padding: var(--ps-s-2) var(--ps-s-3);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-2);
  background-color: var(--ps-surface-2);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-sm);
}
.ps-chat__status[data-tone="error"] {
  border-color: var(--ps-error-edge);
  background-color: var(--ps-error-tint);
  color: var(--ps-error-text);
}
/* chat.js sets tone="gate" when the persona is above the viewer's plan and puts
   a crown, a sentence and a "See plans" button in here. It was falling through
   to the neutral status style, so the one upsell on the core screen looked like
   an error-adjacent grey notice. Warm is the product's aspiration voice (§5). */
.ps-chat__status[data-tone="gate"] {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--ps-s-2);
  border-color: var(--ps-warm-edge);
  background-color: var(--ps-warm-tint);
  color: var(--ps-text-1);
}
.ps-chat__status[data-tone="gate"] > .ps-icon { color: var(--ps-warm-text); }
.ps-chat__status[data-tone="gate"] > span { flex: 1 1 auto; min-inline-size: 0; }
.ps-chat__status:empty { display: none; }

.ps-composer {
  display: flex;
  align-items: flex-end;
  gap: var(--ps-s-2);
  inline-size: 100%;
  max-inline-size: var(--ps-composer-max);
  margin-inline: auto;
  padding: var(--ps-s-2);
  border: var(--ps-bw) solid var(--ps-border-strong);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-sunken);
  transition: border-color var(--ps-t-base), box-shadow var(--ps-t-base);
}
/* ONE focus indicator for the whole composer. v1 gave #chatInput a permanent
   2px accent ring on top of the container border, so a user could not tell
   focused from unfocused. */
.ps-composer:focus-within {
  border-color: var(--ps-accent);
  box-shadow: var(--ps-focus-ring);
}
.ps-composer__input {
  flex: 1 1 auto;
  min-inline-size: 0;
  min-block-size: var(--ps-s-7);
  max-block-size: calc(var(--ps-s-12) + var(--ps-s-9));
  padding: var(--ps-s-2);
  border: 0;
  background-color: transparent;
  color: var(--ps-text-1);
  font-size: var(--ps-fs-md);
  line-height: var(--ps-lh-relaxed);
  resize: none;
}
.ps-composer__input:focus-visible { outline: none; }
.ps-composer__send {
  flex: none;
  background-color: var(--ps-accent);
  color: var(--ps-on-accent);
}
.ps-composer__send:hover { background-color: var(--ps-accent-hover); color: var(--ps-on-accent); }
.ps-composer__send:disabled { background-color: var(--ps-surface-3); color: var(--ps-text-disabled); }
.ps-composer__stop:not([hidden]) {
  display: inline-grid;
  flex: none;
  border-color: var(--ps-error-edge);
  background-color: var(--ps-error-tint);
  color: var(--ps-error-text);
}
.ps-composer__hint {
  inline-size: 100%;
  max-inline-size: var(--ps-composer-max);
  margin-inline: auto;
  color: var(--ps-text-3);
  font-size: var(--ps-fs-xs);
}

@media (max-width: 479.98px) {
  /* Icon-only in the chat header at phone width, but the label stays in the
     accessibility tree rather than being display:none'd away. */
  .ps-chat__actions .ps-btn__label {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
  .ps-chat__actions .ps-btn { padding-inline: var(--ps-s-3); }
}


/* ==========================================================================
   10. FORMS
   One renderer produces both the create and the edit form (partials/creator.php),
   so there is one set of styles for both and they cannot drift the way v1's two
   200-line copies did.
   ========================================================================== */
.ps-form { display: flex; flex-direction: column; gap: var(--ps-s-5); }

/* Empty and inert until the JS fills it, so it takes no space until it means
   something. */
.ps-form__errors:not(:empty) {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-2);
  padding: var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-error-edge);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-error-tint);
  color: var(--ps-error-text);
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-medium);
}
.ps-form__errors:focus-visible { outline-offset: 0; }
/* js/render.js errorSummary() fills the box above with a title and a list of
   real links to the offending fields. Neither had a style, so the summary was a
   red block with an unindented, unmarkered list in it. */
.ps-form__errtitle {
  display: flex;
  align-items: center;
  gap: var(--ps-s-2);
  color: var(--ps-error-text);
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-semibold);
}
.ps-form__errtitle > .ps-icon { flex: none; }
.ps-form__errlist {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-1);
  padding-inline-start: var(--ps-s-5);
  list-style: disc;
}
.ps-form__errlist li::marker { color: var(--ps-error-text); }
/* The link to the field is the whole point of a summary, so it is a link that
   looks like one — and it clears the tap floor without a filled box. */
.ps-form__errlist a {
  display: inline-block;
  min-height: var(--ps-s-6);
  color: var(--ps-error-text);
  font-weight: var(--ps-fw-medium);
}
.ps-form__errlist a:hover { color: var(--ps-text-1); }

.ps-form__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  /* Each fieldset is its own natural height. Stretched (the grid default) the
     two-field AVATAR panel was dragged to the height of the three-field IDENTITY
     panel beside it — 620px of empty violet once a failed submit added three
     error lines to the left column, which read as a missing section rather than
     as a short one. */
  align-items: start;
  gap: var(--ps-s-5);
}
.ps-fieldset {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-4);
  min-inline-size: 0;
  padding: var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-surface-1);
}
.ps-fieldset > legend {
  padding-inline: var(--ps-s-2);
  color: var(--ps-text-3);
  font-size: var(--ps-fs-2xs);
  font-weight: var(--ps-fw-semibold);
  letter-spacing: var(--ps-ls-label);
  text-transform: uppercase;
}
.ps-optional { color: var(--ps-text-3); font-weight: var(--ps-fw-normal); text-transform: none; letter-spacing: var(--ps-ls-normal); }

@media (min-width: 768px) {
  .ps-form__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .ps-fieldset--wide { grid-column: 1 / -1; }
}

/* --- fields ------------------------------------------------------------- */
.ps-field {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-2);
  min-inline-size: 0;
}
.ps-field > label {
  color: var(--ps-text-2);
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-medium);
}
.ps-req { color: var(--ps-error-text); }
.ps-field__hint {
  color: var(--ps-text-3);
  font-size: var(--ps-fs-xs);
  line-height: var(--ps-lh-normal);
}

.ps-field input:not([type="checkbox"], [type="radio"]),
.ps-field select,
.ps-field textarea {
  inline-size: 100%;
  min-height: var(--ps-tap-min);
  padding: var(--ps-s-2) var(--ps-s-3);
  border: var(--ps-bw) solid var(--ps-border-strong);
  border-radius: var(--ps-r-control);
  background-color: var(--ps-sunken);
  color: var(--ps-text-1);
  font-size: var(--ps-fs-md);
  transition: border-color var(--ps-t-base), box-shadow var(--ps-t-base), background-color var(--ps-t-base);
}
.ps-field textarea { min-height: auto; line-height: var(--ps-lh-relaxed); }
/* Hover and focus tint the border violet, but never on a field that is CURRENTLY
   INVALID. Without the :not() the two rules below outrank the error rule that
   follows — hover is (0,3,1), focus (0,2,1), the error was (0,2,0) — so the first
   invalid field, the one creator.js moves focus to, repainted itself violet the
   instant it was focused and read as "focused and fine" directly above the words
   "Give the persona a name". Excluding the state here is stronger than raising
   the error rule's specificity: it cannot be undone by a later hover. Focus is
   still unmistakable, because the focus affordance is the outline ring, not the
   border colour. */
.ps-field :is(input, select, textarea):hover:not(:disabled, [aria-invalid="true"]) { border-color: var(--ps-accent-edge); }
.ps-field :is(input, select, textarea):focus-visible:not([aria-invalid="true"]) { border-color: var(--ps-accent); }
/* Set by creator.js on a failed submit; a weight change as well as a colour. */
.ps-field :is(input, select, textarea)[aria-invalid="true"] {
  border-width: var(--ps-bw-strong);
  border-color: var(--ps-error);
  background-color: var(--ps-error-tint);
}
.ps-field__error {
  display: flex;
  align-items: center;
  gap: var(--ps-s-2);
  color: var(--ps-error-text);
  font-size: var(--ps-fs-xs);
  font-weight: var(--ps-fw-medium);
}
/* The file input is the one control the UA draws entirely by itself, and left
   alone it was the worst-looking element in the product: a native macOS grey
   "Choose File" button, in the platform's own font, sitting on the violet field
   fill — the same "this is a rendering fault" read as the unstyled checkbox.
   `::file-selector-button` is the whole fix; the surrounding field keeps the
   shared input chrome so it lines up with the URL field beneath it. Vertically
   centred rather than top-aligned, because the UA's default alignment left the
   button hanging off the top edge once the field grew to --ps-control-h. */
.ps-field input[type="file"] {
  display: flex;
  align-items: center;
  gap: var(--ps-s-3);
  padding: var(--ps-s-2);
  background-color: var(--ps-surface-2);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-sm);
  cursor: pointer;
}
.ps-field input[type="file"]::file-selector-button {
  flex: none;
  /* A real tap target: the UA button is ~22px tall, which fails the 44px floor
     on its own, so the FIELD is the hit area (the whole control opens the
     picker, and the field is --ps-tap-min tall) and the button paints at the
     same 32px as .ps-btn--sm. */
  min-block-size: var(--ps-s-7);
  margin-inline-end: var(--ps-s-3);
  padding-inline: var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-border-strong);
  border-radius: var(--ps-r-control);
  background-color: var(--ps-surface-3);
  color: var(--ps-text-1);
  font-family: inherit;
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-medium);
  cursor: pointer;
  transition:
    background-color var(--ps-t-fast),
    border-color var(--ps-t-fast);
}
.ps-field input[type="file"]:hover:not(:disabled)::file-selector-button {
  border-color: var(--ps-accent);
  background-color: var(--ps-surface-3-hover);
}
.ps-field input[type="file"]:disabled::file-selector-button {
  border-color: var(--ps-border);
  color: var(--ps-text-3);
  cursor: not-allowed;
}

/* Search field: the icon is decoration, so it never eats the click. */
.ps-field--search { position: relative; flex: 1 1 var(--ps-card-min); min-inline-size: 0; }
.ps-field--search .ps-field__icon {
  position: absolute;
  inset-inline-start: var(--ps-s-3);
  inset-block-start: 50%;
  transform: translateY(-50%);
  color: var(--ps-text-3);
  pointer-events: none;
}
/* [type="search"] is not decoration: the base field rule below carries a
   :not([type="checkbox"], [type="radio"]) and therefore (0,2,1), so a bare
   `.ps-field--search input` at (0,1,1) loses and the placeholder renders on top
   of the magnifier — measured. */
.ps-field--search input[type="search"] { padding-inline-start: calc(var(--ps-s-5) + var(--ps-icon-md)); }

.ps-field--sort { flex-direction: row; align-items: center; gap: var(--ps-s-2); flex: none; }
.ps-field--sort > label { flex: none; color: var(--ps-text-3); }
.ps-field--sort select { inline-size: auto; }

.ps-field--row { flex-direction: row; align-items: center; gap: var(--ps-s-3); }
.ps-field--row > label { flex: none; min-inline-size: var(--ps-s-11); color: var(--ps-text-3); font-size: var(--ps-fs-xs); }

/* Checkbox / radio.
   This started as the native control with `accent-color`, on the theory that a
   custom one is another thing to get wrong for assistive tech. Rendered, that
   theory only held for the CHECKED state: `accent-color` paints the fill of a
   checked box and does nothing at all to an unchecked one, so on dark the Pro
   filter was a bare ~#3A3A3A UA square that read as a rendering fault, and on
   light it was an empty hairline outline with no relationship to any other
   control on the page.

   So the box is drawn here, and only the box: it is still a real
   `<input type="checkbox">` with a real `<label for>`, so the accessibility tree,
   the tab order, Space, the form value and `:checked` are all the platform's. The
   tick is a background image because a pseudo-element on an `<input>` — the usual
   trick — is not rendered by Firefox on a replaced element. Its stroke colour is
   baked into the SVG and therefore lives in tokens.css §1.16, so this file still
   declares no colour of its own. */
.ps-field--check {
  flex-direction: row;
  align-items: center;
  gap: var(--ps-s-3);
  min-height: var(--ps-tap-min);
}
.ps-field--check input[type="checkbox"],
.ps-radio input[type="radio"] {
  -webkit-appearance: none;
          appearance: none;
  flex: none;
  display: grid;
  place-content: center;
  inline-size: var(--ps-icon-md);
  block-size: var(--ps-icon-md);
  border: var(--ps-bw-strong) solid var(--ps-border-strong);
  /* A RECESSED WELL, not the surface it sits on. --ps-surface-1 was the same
     colour as the filter pill and as the panel behind it, so the unchecked box
     was a hollow 2px outline with nothing inside it — an empty rounded square
     that read as an unstyled artefact rather than as a control, which is exactly
     how the search field beside it would read if it had no fill either.
     --ps-sunken plus --ps-e-inset is the SAME treatment the text inputs get in
     this section, so the box now belongs to the same family of controls: a well
     you can put a mark into. */
  background-color: var(--ps-sunken);
  background-position: center;
  background-repeat: no-repeat;
  background-size: var(--ps-icon-sm) var(--ps-icon-sm);
  box-shadow: var(--ps-e-inset);
  cursor: pointer;
  transition:
    background-color var(--ps-t-fast),
    border-color var(--ps-t-fast),
    box-shadow var(--ps-t-fast);
}
.ps-field--check input[type="checkbox"] { border-radius: var(--ps-r-1); }
.ps-radio input[type="radio"] { border-radius: var(--ps-r-circle); }

/* Hover moves the FILL as well as the border. Border-alone was too quiet to
   answer "is this thing interactive?" — the question the unchecked box was
   failing to answer at all. The accent tint is the "about to be on" step: it is
   the checked fill's own family, one huge luminance move short of it, so the
   hover previews the action rather than just acknowledging the pointer. */
.ps-field--check input[type="checkbox"]:hover:not(:disabled),
.ps-radio input[type="radio"]:hover:not(:disabled) {
  border-color: var(--ps-accent);
  background-color: var(--ps-accent-tint);
}

/* Checked and indeterminate DROP the inset well shadow: a box that has been
   filled is no longer a well, and an inset shadow over the accent fill only
   muddies the top edge of the tick. */
.ps-field--check input[type="checkbox"]:checked {
  border-color: var(--ps-accent);
  background-color: var(--ps-accent);
  background-image: var(--ps-img-check);
  box-shadow: none;
}
.ps-field--check input[type="checkbox"]:indeterminate {
  border-color: var(--ps-accent);
  background-color: var(--ps-accent);
  background-image: var(--ps-img-dash);
  box-shadow: none;
}
/* The radio's dot is an inset ring in the surface colour rather than a second
   image: one fill, one ring, and it scales with the box. */
.ps-radio input[type="radio"]:checked {
  border-color: var(--ps-accent);
  background-color: var(--ps-accent);
  box-shadow: inset 0 0 0 var(--ps-s-1) var(--ps-surface-1);
}
.ps-field--check input[type="checkbox"]:disabled,
.ps-radio input[type="radio"]:disabled {
  border-color: var(--ps-border);
  background-color: var(--ps-surface-2);
  /* Flat, because a disabled control should not look like it is waiting for
     input, and the inset well is precisely the "put something here" signal. */
  box-shadow: none;
}
/* The box is 20px, which is under the 44px floor, so the HIT AREA is the label
   and — for the filter pill below — the whole pill. */
.ps-field--check input[type="checkbox"]:focus-visible,
.ps-radio input[type="radio"]:focus-visible {
  outline: var(--ps-focus-outline);
  outline-offset: var(--ps-focus-offset);
}
.ps-field--check > label {
  display: inline-flex;
  align-items: center;
  gap: var(--ps-s-2);
  min-height: var(--ps-tap-min);
  color: var(--ps-text-2);
  cursor: pointer;
}
/* The Pro-only filter reads as a control: v1 signalled it with colour alone and
   gave it no focus indicator at all. */
.ps-filters .ps-field--check {
  position: relative;
  gap: var(--ps-s-2);
  padding-inline: var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-border-strong);
  border-radius: var(--ps-r-pill);
  background-color: var(--ps-surface-1);
  transition:
    background-color var(--ps-t-base),
    border-color var(--ps-t-base),
    box-shadow var(--ps-t-base);
}
/* Exactly --ps-tap-min TALL, like the search field and the Search button either
   side of it. The shared .ps-field--check rule gives the LABEL a 44px min-height
   as its own hit area, which is right when the label is the only target — but
   here the pill is the target, and a 44px child inside a bordered parent makes
   the parent 46px, because `box-sizing: border-box` governs a declared height and
   not a content-driven one. Measured: a 46px pill beside 44px neighbours, and
   48px once checked. The pill's own min-height (inherited, border-box) now
   governs, and the label stops contributing a height of its own. */
/* Semibold in BOTH states, which is also what the Search button beside it is.
   Bolding the label only when checked re-measured the text and grew the pill
   203.4 -> 206.0px, so ticking the box nudged the Search button sideways: the same
   class of state-driven layout shift as the border-width above, just subtler. The
   checked state loses nothing by it — it still carries the tick glyph, the inset
   ring, the warm fill and the input's own :checked, so it is nowhere near
   colour-alone. */
.ps-filters .ps-field--check > label {
  min-height: 0;
  font-weight: var(--ps-fw-semibold);
}
.ps-filters .ps-field--check:hover { background-color: var(--ps-surface-1-hover); }
/* The whole pill is the hit target, not the 20px box: the label is stretched
   over it, so tapping anywhere inside 44px-tall pill toggles the filter. The
   overlay is on the label so it is still the LABEL that is clicked, and the
   native label-for behaviour does the toggling. */
.ps-filters .ps-field--check > label::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
}
/* Checked doubles the ring with an INSET SHADOW rather than with border-width.
   `border-width: 2px` grew the pill from 46x203 to 48x208 the instant the box was
   ticked, which nudged the Search button beside it and, at 390px where the pill is
   its own grid row, moved the whole chip rail down 2px — a layout shift caused by
   a state change, on a control whose entire job is to be toggled. An inset ring
   paints inside the existing border box, so the emphasis is identical and the
   geometry is frozen. */
.ps-filters .ps-field--check:has(input:checked) {
  border-color: var(--ps-warm);
  background-color: var(--ps-warm-tint);
  box-shadow: inset 0 0 0 var(--ps-bw) var(--ps-warm);
}
.ps-filters .ps-field--check:has(input:checked) > label { color: var(--ps-warm-text); }
/* A violet box inside an amber pill fought the pill, so the checked box takes
   the warm fill and the dark tick that goes with it (tokens.css §1.16). */
.ps-filters .ps-field--check input[type="checkbox"]:checked {
  border-color: var(--ps-warm-edge);
  background-color: var(--ps-warm);
  background-image: var(--ps-img-check-dark);
}
/* THE WHOLE PILL takes the focus ring, not the 20px box inside it. The box is
   what the platform focuses, but the pill is what a sighted user perceives as the
   control — a 20px ring floating at the left end of a 203px pill reads as a ring
   around something else. Both rules are gated on :has(), so if :has() is
   unavailable the suppression disappears WITH the pill ring and the box keeps the
   default outline from the shared rule above: the failure mode is the old, less
   pretty indicator, never no indicator. */
.ps-filters .ps-field--check:has(input:focus-visible) {
  border-color: var(--ps-accent);
  box-shadow: var(--ps-focus-ring);
}
.ps-filters .ps-field--check:has(input:focus-visible) input[type="checkbox"] { outline: none; }
/* Checked AND focused: keep the checked inset ring under the focus halo, so the
   state is still readable while the ring is up. */
.ps-filters .ps-field--check:has(input:checked):has(input:focus-visible) {
  box-shadow: inset 0 0 0 var(--ps-bw) var(--ps-warm), var(--ps-focus-ring);
}

.ps-field__preview:not(:empty) {
  display: flex;
  align-items: center;
  gap: var(--ps-s-3);
  padding: var(--ps-s-2);
  border: var(--ps-bw) dashed var(--ps-border-strong);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-surface-2);
}
.ps-field__preview img { border-radius: var(--ps-r-2); }
/* js/render.js avatarPreview(): sized by attribute, so this only has to keep a
   tall portrait from stretching the dialog. */
.ps-preview__img {
  flex: none;
  max-block-size: var(--ps-s-11);
  inline-size: auto;
  border-radius: var(--ps-r-2);
  object-fit: cover;
}

/* Starter inputs stack; the row form (--row) is what aligns their labels. */
.ps-starters__inputs {
  display: flex;
  flex-direction: column;
  gap: var(--ps-s-2);
}

/* --- removable tag chips (js/render.js tagChip) ------------------------- */
.ps-tag--removable {
  display: inline-flex;
  align-items: center;
  gap: var(--ps-s-1);
  padding: var(--ps-s-1) var(--ps-s-1) var(--ps-s-1) var(--ps-s-3);
  border: var(--ps-bw) solid var(--ps-border-strong);
  border-radius: var(--ps-r-pill);
  background-color: var(--ps-surface-2);
  color: var(--ps-text-1);
  font-size: var(--ps-fs-sm);
}
.ps-tag__remove {
  display: grid;
  place-items: center;
  flex: none;
  inline-size: var(--ps-s-6);
  block-size: var(--ps-s-6);
  border-radius: var(--ps-r-circle);
  color: var(--ps-text-3);
  transition: background-color var(--ps-t-fast), color var(--ps-t-fast);
}
/* Small paint, 44px target — the same overhang as .ps-btn--sm (§5). */
.ps-tag__remove { position: relative; }
.ps-tag__remove::after {
  content: "";
  position: absolute;
  inset: calc((var(--ps-tap-min) - var(--ps-s-6)) / -2);
}
.ps-tag__remove:hover { background-color: var(--ps-error-tint); color: var(--ps-error-text); }

/* --- radio cards -------------------------------------------------------- */
.ps-radios { display: grid; gap: var(--ps-s-3); }
.ps-radio {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: start;
  gap: var(--ps-s-3);
  padding: var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-border-strong);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-surface-1);
  transition: border-color var(--ps-t-base), background-color var(--ps-t-base);
}
.ps-radio:hover { background-color: var(--ps-surface-1-hover); }
.ps-radio:has(input:checked) {
  border-width: var(--ps-bw-strong);
  border-color: var(--ps-accent);
  background-color: var(--ps-accent-tint);
}
.ps-radio > label { display: flex; flex-direction: column; gap: var(--ps-s-1); cursor: pointer; }
.ps-radio__title {
  display: flex;
  align-items: center;
  gap: var(--ps-s-2);
  color: var(--ps-text-1);
  font-size: var(--ps-fs-md);
  font-weight: var(--ps-fw-semibold);
}
.ps-radio__desc { color: var(--ps-text-3); font-size: var(--ps-fs-sm); }

/* --- actions row -------------------------------------------------------- */
.ps-form__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: var(--ps-s-3);
}
/* In the consent dialogs this row is the panel's footer. */
.ps-dialog__panel > .ps-form__actions {
  padding: var(--ps-s-4) var(--ps-s-5) max(var(--ps-s-4), env(safe-area-inset-bottom));
  border-block-start: var(--ps-bw) solid var(--ps-border);
  background-color: var(--ps-surface-1);
}


/* ==========================================================================
   11. LISTS: leaderboard, my personas, announcements, chat history
   ========================================================================== */
.ps-ranked { display: flex; flex-direction: column; gap: var(--ps-s-2); }
.ps-ranked__row {
  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--ps-s-3);
  padding: var(--ps-s-2) var(--ps-s-3);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-surface-1);
}
.ps-ranked__pos {
  display: grid;
  place-items: center;
  inline-size: var(--ps-s-7);
  block-size: var(--ps-s-7);
  border: var(--ps-bw) solid transparent;
  border-radius: var(--ps-r-circle);
  background-color: var(--ps-surface-3);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-bold);
  font-variant-numeric: tabular-nums;
}
/* Gold, silver, bronze — and a real treatment for 4th onwards, which v1 had
   nothing for at all. */
.ps-ranked__row:nth-child(1) .ps-ranked__pos { background-color: var(--ps-medal-1); border-color: var(--ps-medal-edge); color: var(--ps-on-medal); }
.ps-ranked__row:nth-child(2) .ps-ranked__pos { background-color: var(--ps-medal-2); border-color: var(--ps-medal-edge); color: var(--ps-on-medal); }
.ps-ranked__row:nth-child(3) .ps-ranked__pos { background-color: var(--ps-medal-3); border-color: var(--ps-medal-edge); color: var(--ps-on-medal); }
.ps-ranked__avatar {
  inline-size: var(--ps-s-8);
  block-size: var(--ps-s-8);
  border-radius: var(--ps-r-avatar);
  object-fit: cover;
  background-color: var(--ps-surface-2);
}
.ps-ranked__name { min-inline-size: 0; }
.ps-ranked__name a {
  display: block;
  color: var(--ps-text-1);
  font-weight: var(--ps-fw-medium);
  text-decoration: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ps-ranked__name a:hover { color: var(--ps-accent-text); text-decoration: underline; }
.ps-ranked__count {
  display: inline-flex;
  align-items: center;
  gap: var(--ps-s-1);
  color: var(--ps-text-3);
  font-size: var(--ps-fs-sm);
  font-variant-numeric: tabular-nums;
}

.ps-minelist { display: flex; flex-direction: column; gap: var(--ps-s-3); }
.ps-minelist__row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: start;
  gap: var(--ps-s-3);
  padding: var(--ps-s-3);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-surface-1);
}
.ps-minelist__avatar {
  inline-size: var(--ps-s-9);
  block-size: var(--ps-s-9);
  border-radius: var(--ps-r-3);
  object-fit: cover;
  background-color: var(--ps-surface-2);
}
.ps-minelist__main { display: flex; flex-direction: column; gap: var(--ps-s-1); min-inline-size: 0; }
.ps-minelist__name { font-size: var(--ps-fs-md); font-weight: var(--ps-fw-semibold); }
.ps-minelist__name a { color: var(--ps-text-1); text-decoration: none; }
.ps-minelist__name a:hover { color: var(--ps-accent-text); text-decoration: underline; }
.ps-minelist__tagline { color: var(--ps-text-3); font-size: var(--ps-fs-sm); }
.ps-minelist__meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--ps-s-2); padding-block-start: var(--ps-s-1); }
.ps-minelist__chats {
  display: inline-flex;
  align-items: center;
  gap: var(--ps-s-1);
  color: var(--ps-text-3);
  font-size: var(--ps-fs-xs);
  font-variant-numeric: tabular-nums;
}
.ps-minelist__actions {
  grid-column: 1 / -1;
  display: flex;
  flex-wrap: wrap;
  gap: var(--ps-s-2);
}

@media (min-width: 768px) {
  .ps-minelist__row { grid-template-columns: auto minmax(0, 1fr) auto; }
  .ps-minelist__actions { grid-column: auto; }
}

.ps-notiflist { display: flex; flex-direction: column; gap: var(--ps-s-2); }
.ps-notiflist__row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--ps-s-3);
  padding: var(--ps-s-3);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-surface-1);
}
/* Unread is a tint, a border AND the word "Unread" — never colour alone. */
.ps-notiflist__row[data-read="false"] {
  border-color: var(--ps-accent-edge);
  background-color: var(--ps-accent-tint);
}
.ps-notiflist__icon {
  display: grid;
  place-items: center;
  inline-size: var(--ps-s-7);
  block-size: var(--ps-s-7);
  border-radius: var(--ps-r-circle);
  background-color: var(--ps-surface-3);
  color: var(--ps-accent-ui);
}
.ps-notiflist__msg { color: var(--ps-text-1); font-size: var(--ps-fs-sm); line-height: var(--ps-lh-relaxed); }
.ps-notiflist__when {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ps-s-2);
  padding-block-start: var(--ps-s-1);
  color: var(--ps-text-3);
  font-size: var(--ps-fs-xs);
}

.ps-chatlist { display: flex; flex-direction: column; gap: var(--ps-s-2); }
.ps-chatlist__row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--ps-s-3);
  padding: var(--ps-s-2) var(--ps-s-3);
  border: var(--ps-bw) solid var(--ps-border);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-surface-1);
  transition: background-color var(--ps-t-fast), border-color var(--ps-t-fast);
}
.ps-chatlist__row:hover { background-color: var(--ps-surface-1-hover); border-color: var(--ps-accent-edge); }
.ps-chatlist__avatar {
  inline-size: var(--ps-s-8);
  block-size: var(--ps-s-8);
  border-radius: var(--ps-r-avatar);
  object-fit: cover;
  background-color: var(--ps-surface-2);
}
.ps-chatlist__main { display: flex; flex-direction: column; gap: var(--ps-s-h); min-inline-size: 0; }
.ps-chatlist__name { color: var(--ps-text-1); font-size: var(--ps-fs-sm); font-weight: var(--ps-fw-semibold); }
.ps-chatlist__excerpt {
  color: var(--ps-text-3);
  font-size: var(--ps-fs-xs);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ps-chatlist__when { color: var(--ps-text-3); font-size: var(--ps-fs-xs); font-variant-numeric: tabular-nums; }
.ps-chatlist__loading,
.ps-chatlist__empty { padding: var(--ps-s-6); color: var(--ps-text-3); font-size: var(--ps-fs-sm); text-align: center; }

.ps-tabs { display: flex; flex-wrap: wrap; gap: var(--ps-s-2); }


/* ==========================================================================
   12. FEEDBACK STATES: skeleton, empty, error, toast
   v1 defined .empty-state and .persona-card-skeleton and used NEITHER. These
   are the states the product actually passes through, and they are shaped like
   the content they stand in for.
   ========================================================================== */
.ps-skeleton,
.ps-skeleton__media,
.ps-skeleton__line,
.ps-skeleton__chip {
  border-radius: var(--ps-r-2);
  background-color: var(--ps-skeleton-a);
  background-image: linear-gradient(
    100deg,
    var(--ps-skeleton-a) 0%,
    var(--ps-skeleton-b) 45%,
    var(--ps-skeleton-a) 90%
  );
  background-size: 300% 100%;
  animation: ps-shimmer var(--ps-dur-shimmer) var(--ps-ease-linear) infinite;
}
@keyframes ps-shimmer {
  from { background-position: 150% 0; }
  to   { background-position: -150% 0; }
}
@media (prefers-reduced-motion: reduce) {
  .ps-skeleton,
  .ps-skeleton__media,
  .ps-skeleton__line,
  .ps-skeleton__chip { animation: none; background-image: none; }
}

/* A skeleton card is a real .ps-card with skeleton parts inside it, so the grid
   does not resize when the real cards arrive.

   js/render.js puts `ps-skeleton` on the CARD as well as on the parts inside it,
   so the shared paint above filled the whole card with the shimmer — and the
   media block and both text lines, being exactly the same colour, vanished into
   it. Eight identical 380px violet rectangles, which is the opposite of a
   skeleton: it has to be shaped like the content it replaces (SPEC §8). The
   container therefore reverts to the real card's own surface and the parts do
   all the shimmering. */
.ps-card--skeleton {
  pointer-events: none;
  box-shadow: none;
  background-color: var(--ps-surface-1);
  background-image: none;
  animation: none;
}
.ps-card--skeleton .ps-skeleton__media { aspect-ratio: 4 / 3; border-radius: 0; }
.ps-card--skeleton .ps-card__body { gap: var(--ps-s-3); }
/* js/render.js emits a media block and two lines; a real card also carries a tag
   row, and without it the skeleton stopped 28px short of the card it replaces and
   the grid stepped when the data arrived. The third block is a pseudo-element
   rather than a JS change, and it inherits the shimmer by sharing the token. */
.ps-card--skeleton .ps-card__body::after {
  content: "";
  align-self: start;
  inline-size: var(--ps-s-11);
  block-size: var(--ps-s-6);
  border-radius: var(--ps-r-pill);
  background-color: var(--ps-skeleton-a);
  background-image: linear-gradient(
    100deg,
    var(--ps-skeleton-a) 0%,
    var(--ps-skeleton-b) 45%,
    var(--ps-skeleton-a) 90%
  );
  background-size: 300% 100%;
  animation: ps-shimmer var(--ps-dur-shimmer) var(--ps-ease-linear) infinite;
}
@media (prefers-reduced-motion: reduce) {
  .ps-card--skeleton .ps-card__body::after { animation: none; background-image: none; }
}
.ps-skeleton__line { block-size: calc(var(--ps-fs-md) * var(--ps-lh-normal)); }
.ps-skeleton__line--title {
  block-size: calc(var(--ps-fs-lg) * var(--ps-lh-snug));
  inline-size: 70%;
}
.ps-skeleton__line--short { inline-size: 45%; }
.ps-skeleton__chip { block-size: var(--ps-s-5); inline-size: var(--ps-s-11); border-radius: var(--ps-r-pill); }
.ps-skeleton--bubble {
  block-size: calc(var(--ps-s-12) * 0.5);
  inline-size: 60%;
  border-radius: var(--ps-r-bubble);
}

/* --- empty / zero-results / error --------------------------------------- */
.ps-empty,
.ps-grid__empty,
.ps-minelist__empty,
.ps-ranked__row--empty,
.ps-notiflist__empty {
  grid-column: 1 / -1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--ps-s-3);
  padding: var(--ps-s-9) var(--ps-s-5);
  border: var(--ps-bw) dashed var(--ps-border-strong);
  border-radius: var(--ps-r-card);
  background-color: var(--ps-surface-1);
  color: var(--ps-text-2);
  font-size: var(--ps-fs-sm);
  text-align: center;
}
.ps-empty > .ps-icon,
.ps-grid__empty > .ps-icon,
.ps-empty__icon {
  display: grid;
  place-items: center;
  inline-size: var(--ps-s-9);
  block-size: var(--ps-s-9);
  border-radius: var(--ps-r-circle);
  background-color: var(--ps-accent-tint);
  color: var(--ps-accent-ui);
}
.ps-empty strong,
.ps-grid__empty strong,
.ps-minelist__empty strong,
.ps-empty__title {
  color: var(--ps-text-1);
  font-size: var(--ps-fs-lg);
  font-weight: var(--ps-fw-semibold);
}
.ps-empty__body { max-inline-size: var(--ps-prose-max); color: var(--ps-text-2); }
.ps-empty--error { border-style: solid; border-color: var(--ps-error-edge); background-color: var(--ps-error-tint); }
.ps-empty--error > .ps-icon,
.ps-empty--error .ps-empty__icon { background-color: var(--ps-error-tint); color: var(--ps-error-text); }

/* --- toasts ------------------------------------------------------------
   The one deliberate exception to "everything below the shared nav": a toast
   must outrank the shared upgrade modal (1000000, external) or a confirmation
   is invisible behind it. */
.ps-toasts {
  position: fixed;
  inset-inline: var(--ps-s-4);
  inset-block-end: max(var(--ps-s-5), env(safe-area-inset-bottom));
  z-index: var(--ps-z-toast);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--ps-s-2);
  pointer-events: none;
}
.ps-toast {
  display: flex;
  align-items: center;
  gap: var(--ps-s-3);
  inline-size: fit-content;
  max-inline-size: 100%;
  padding: var(--ps-s-3) var(--ps-s-4);
  border: var(--ps-bw) solid var(--ps-border-strong);
  border-radius: var(--ps-r-3);
  background-color: var(--ps-surface-2);
  color: var(--ps-text-1);
  font-size: var(--ps-fs-sm);
  box-shadow: var(--ps-e-3);
  pointer-events: auto;
  animation: ps-toast-in var(--ps-t-spring) both;
}
.ps-toast--success { border-color: var(--ps-success-edge); background-color: var(--ps-success-tint); }
.ps-toast--success > .ps-icon { color: var(--ps-success-text); }
.ps-toast--error { border-color: var(--ps-error-edge); background-color: var(--ps-error-tint); }
.ps-toast--error > .ps-icon { color: var(--ps-error-text); }
.ps-toast--warning { border-color: var(--ps-warning-edge); background-color: var(--ps-warning-tint); }
.ps-toast--warning > .ps-icon { color: var(--ps-warning-text); }
@keyframes ps-toast-in {
  from { opacity: 0; transform: translateY(var(--ps-s-4)); }
  to   { opacity: 1; transform: none; }
}

/* The three parts js/modal.js builds inside a toast. None had a style, so the
   message did not shrink (a long one pushed the close button off the end), the
   action read as body text and the dismiss button was an unstyled 16px glyph. */
.ps-toast__msg { flex: 1 1 auto; min-inline-size: 0; }
.ps-toast__action {
  flex: none;
  min-height: var(--ps-s-7);
  padding-inline: var(--ps-s-2);
  border-radius: var(--ps-r-1);
  color: var(--ps-accent-text);
  font-size: var(--ps-fs-sm);
  font-weight: var(--ps-fw-semibold);
  text-decoration: underline;
}
.ps-toast__action:hover { color: var(--ps-text-1); }
.ps-toast__close {
  display: grid;
  place-items: center;
  flex: none;
  inline-size: var(--ps-s-6);
  block-size: var(--ps-s-6);
  border-radius: var(--ps-r-circle);
  color: var(--ps-text-3);
  transition: background-color var(--ps-t-fast), color var(--ps-t-fast);
}
.ps-toast__close:hover { background-color: var(--ps-surface-3); color: var(--ps-text-1); }
/* Small paint, 44px target, as in §5. */
.ps-toast__action,
.ps-toast__close { position: relative; }
.ps-toast__action::after,
.ps-toast__close::after {
  content: "";
  position: absolute;
  inset-block: calc((var(--ps-tap-min) - var(--ps-s-6)) / -2);
  inset-inline: 0;
}
/* The close is 24px SQUARE, so `inset-inline: 0` left the target 44 tall and
   only 24 wide — measured `button.ps-toast__close 24x24` in the state sweep,
   the one control in the product still under the floor on an axis. The vertical
   overhang above is enough for a row of side-by-side controls; a square icon
   needs the horizontal one too.

   The overhang is asymmetric on purpose, and both halves are bounded by
   geometry that already exists:
     end   −16px, exactly .ps-toast's own padding-inline, so it stops at the
            toast edge and never reaches the surface behind it;
     start  −8px, one step INSIDE the 12px flex gap, so it cannot touch
            .ps-toast__action's own overlay (which does not extend rightwards).
   24 + 8 + 16 = 48px of target under a 24px circle. */
.ps-toast__close::after {
  inset-inline: calc(var(--ps-s-2) * -1) calc(var(--ps-s-4) * -1);
}

/* modal.js adds .is-leaving 300ms before it removes the node. Unstyled, a toast
   vanished on a frame boundary; --ps-dur-4 (280ms) fits inside that window and
   collapses to 1ms under reduced motion like everything else. */
.ps-toast.is-leaving {
  animation: ps-toast-out var(--ps-dur-4) var(--ps-ease-in) both;
  pointer-events: none;
}
@keyframes ps-toast-out {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateY(var(--ps-s-2)) scale(0.98); }
}


/* ==========================================================================
   13. UTILITIES
   ========================================================================== */
/* The clip-path pattern, not `display:none` — the content stays in the
   accessibility tree, which is the entire point of every use of it in the
   partials. */
.ps-visually-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Every timestamp in the product goes through js/render.js timeEl(), which emits
   <time class="ps-when">. Tabular figures so a column of "3 days ago" / "12 Jul"
   does not jitter as the list refreshes. */
.ps-when {
  color: inherit;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* svg is display:block from the reset, which is right for imagery and wrong for
   a glyph sitting in a line of text. */
.ps-icon {
  display: inline-block;
  flex: none;
  vertical-align: middle;
  fill: none;
}

/* The theme toggle ships both glyphs and shows the one that describes what the
   click will DO, resolved by the same cascade tokens.css uses. */
.ps-icon--light-only { display: none; }
.ps-icon--dark-only { display: inline-block; }
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .ps-icon--light-only { display: inline-block; }
  :root:not([data-theme="dark"]) .ps-icon--dark-only { display: none; }
}
:root[data-theme="light"] .ps-icon--light-only { display: inline-block; }
:root[data-theme="light"] .ps-icon--dark-only { display: none; }
:root[data-theme="dark"] .ps-icon--light-only { display: none; }
:root[data-theme="dark"] .ps-icon--dark-only { display: inline-block; }

/* Print: the aurora, the sidebar and every dialog are screen furniture. */
@media print {
  .ps-body::before,
  .ps-sidebar,
  .ps-dialog,
  .ps-pagination,
  .ps-filters { display: none; }
  .ps-shell { display: block; }
  .ps-card { break-inside: avoid; }
}
