/* GENERATED by packages/pi-design/tools/gen-tokens.mjs — do not edit.
 * Barrel: reset + fonts + scale + tokens + the mini app's own tokens + components, in dependency order.
 * Source: plaza/apps/bakery/src/design-system/tokens
 * Regenerate: npm --workspace @pi-miniapps/pi-design run gen
 */

/* ─── reset.css ─── */
/**
 * The minimum reset a Pi mini app needs. Deliberately small — this ships to
 * apps with existing stylesheets, so anything opinionated here is a regression
 * somewhere else.
 */

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  /* Draws the caret, selection highlight and form controls in the right scheme.
     Apps that lock a scheme override this. */
  color-scheme: light dark;
  -webkit-text-size-adjust: 100%;
}

/**
 * `data-theme` IS A DECISION, SO THE UA HAS TO HEAR IT TOO.
 *
 * `light dark` above means "whichever the device is in", which is right for the
 * case it is written for — no explicit theme, `tokens.css`'s
 * `prefers-color-scheme` block following the OS. It is wrong the moment
 * something pins the palette: `pi-theme.js` sets this attribute from the host's
 * `pi_theme`, from a stored choice, or an app's markup ships it outright, and
 * `light dark` then leaves the SCHEME on the device while the TOKENS move. A
 * light-pinned page on a dark phone drew Pi's light palette with a dark caret,
 * dark selection, dark form internals and a dark scrollbar — the seams a viewer
 * reads as the page flickering between two themes on open.
 *
 * TYPE PLUS ATTRIBUTE (0,1,1) so it outranks the bare `:root` (0,1,0) an app's
 * own stylesheet declares `color-scheme` on, whatever the load order — which is
 * what carries this to the v1 apps without editing each of them. An app that
 * really must override still can, with any (0,1,1) of its own.
 */
html[data-theme="light"] {
  color-scheme: light;
}

html[data-theme="dark"] {
  color-scheme: dark;
}

body {
  margin: 0;
}

/* The grey flash on tap in WKWebView; every Pi surface handles its own press
   state, so the UA one is always wrong. */
* {
  -webkit-tap-highlight-color: transparent;
}

/* Anything under 16px makes iOS Safari zoom the viewport on focus, and the
   viewport meta cannot fully suppress it. Controls inherit the app's face
   rather than the UA's. */
input,
textarea,
select,
button {
  font: inherit;
  color: inherit;
}
input,
textarea,
select {
  font-size: max(16px, 1em);
}

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

/* A DEFAULT, not an override — which is why it is wrapped in `:where()`.
 *
 * Bare, this selector is (0,1,0). The `input, textarea { … outline: none }` an
 * app writes to suppress the UA ring is (0,0,1), so the reset won that pair
 * whatever the load order and every field that already drew its own focus
 * treatment got a SECOND ring outside it, 2px away, in accent green. Measured,
 * not reasoned: on the real Droplets page a focused field computes
 * `outline: solid 2px rgb(3,130,71) @2px` OVER its own 1px border in Chromium,
 * WebKit and WebKit-with-touch alike — and a text field matches :focus-visible
 * on a plain TAP, so this was on every user, not only keyboard ones.
 *
 * `:where()` takes the specificity to (0,0,0): an app that says nothing about
 * outline still gets Pi's ring on every focusable thing, and an app that draws
 * its own focus treatment wins with a plain element selector instead of
 * `!important`. Author origin still beats the UA sheet at any specificity, so
 * the ring continues to replace WebKit's rather than sitting beside it.
 */
:where(:focus-visible) {
  outline: 2px solid var(--color-accent-alt-default, currentColor);
  outline-offset: 2px;
}


/* ─── fonts.css ─── */
/**
 * Pi's typography, served from the mini app's own origin.
 *
 * Promoted out of apps/droplets/public/pi-type.css so every mini app draws from
 * one set of outlines instead of each re-deriving them. The five files are
 * sha256-identical to the ones the iOS app registers
 * (`DesignSystem/Sources/DesignSystem/Resources/Fonts`, listed in
 * `RegisterFonts.swift`), so a mini-app screen and the Pi screen behind it draw
 * the same glyphs.
 *
 * Same-origin on purpose: mini-app CSP is `default-src 'self'`, which covers
 * fonts, so a webfont CDN would simply be blocked — silently, since a missing
 * family falls back rather than erroring.
 *
 * urls are RELATIVE so the mount point stays a free variable, and they are
 * `./fonts/` — NOT `../fonts/` — because they resolve against the URL this
 * sheet is SERVED at, not its path on disk. mountPiDesign flattens css/ to the
 * mount root (css/pi-design.css -> <base>/pi-design.css) while keeping fonts
 * nested (<base>/fonts/<face>), so from <base>/pi-design.css the faces are at
 * ./fonts/. `../fonts/` resolves to /fonts/ at the site root, which is a 404 —
 * and a 404 font is SILENT: the page just renders in New York and SF Pro,
 * which is the exact bug this file exists to prevent. test/font-url.test.js
 * asserts the served bytes, not the source, so this cannot regress.
 *
 * Weight coverage is the trap this avoids. Pi ships DM Sans in 500 ONLY and
 * Grenette in 400/700 — no DM Sans regular, no DM Sans bold. A rule asking for
 * 600 against DM Sans gets WebKit's synthetic bold, which looks worse than the
 * SF Pro it replaces. Every weight declared here has a file behind it, and
 * `font-synthesis` refuses fake bold if one ever slips back in.
 */

@font-face {
  font-family: "Grenette Pro";
  src: url("./fonts/GrenettePro-Regular.otf") format("opentype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Grenette Pro";
  src: url("./fonts/GrenettePro-Italic.otf") format("opentype");
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
/* Pi's only bold Grenette style is `body_alt_m_bold`; no heading uses it. Here
   so that emphasis inside serif copy has a real face to reach for. */
@font-face {
  font-family: "Grenette Pro";
  src: url("./fonts/GrenettePro-Bold.otf") format("opentype");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
/* The file's legacy family name is "DM Sans Medium"; its typographic family is
   "DM Sans" at weight 500, which is what this declares. */
@font-face {
  font-family: "DM Sans";
  src: url("./fonts/DMSans-Medium.ttf") format("truetype");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
/* `Font.codeblock` — Pi's only monospace, and only ever for code. */
@font-face {
  font-family: "IBM Plex Mono";
  src: url("./fonts/IBMPlexMono-Regular.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  /* System faces stay behind Pi's, so a font that fails to load degrades to
     what the page would otherwise render rather than to Times. */
  --pi-font-heading: "Grenette Pro", ui-serif, Georgia, "Iowan Old Style", serif;
  --pi-font-body: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  --pi-font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

  /* plaza's typography tokens reference these names but never declare them —
     next/font declares them at the app root, and mini apps have no next/font.
     Declaring them here means type-scale.css resolves correctly if an app
     links it. --font-grenette-condensed points at the regular face because
     that is what it points at upstream; matching plaza means reproducing that.
   */
  --font-grenette: var(--pi-font-heading);
  --font-grenette-condensed: var(--pi-font-heading);
  --font-dm-sans: var(--pi-font-body);
  --font-ibm-plex-mono: var(--pi-font-mono);

  /* Tracking, as em so it holds at every step. Pi tracks 19 of its 20 styles
     negative (`Font+.swift`, `.tracking(…)` in points).
       -0.015em  heading_l/m/s   (-0.6/40, -0.48/32, -0.36/24)
       -0.02em   body_m, action_l, label_s
       -0.01em   heading_xs, body_s/xs, action_s/m, label_xs
        0        label_xxs — the one style Pi leaves alone */
  --pi-track-heading: -0.015em;
  --pi-track-tight: -0.02em;
  --pi-track-body: -0.01em;

  /* Line height. Native line box = size × the face's own line height (Grenette
     1.320, DM Sans 1.302) + the style's `lineSpacing`. lineSpacing sits BETWEEN
     lines, so it only applies once text wraps — which is why one-line labels
     get `--pi-lh-single` (the face's box, nothing added) and paragraphs get the
     tier Pi gives them. */
  --pi-lh-heading: 1.42; /* heading_xs/s/m: 18/24/32 + 1.8/2.4/3.2 */
  --pi-lh-para: 1.7; /* body_s, body_m, label_s once wrapped */
  --pi-lh-copy: 1.4; /* body_xs, action_* — Pi's tighter tier */
  --pi-lh-single: 1.3; /* one line of DM Sans or Grenette: the face's own box */

  /* Fake bold is the way this could look worse than doing nothing, so it is
     refused outright. `style` stays synthesised: DM Sans has no italic file. */
  font-synthesis: style;
  -webkit-font-synthesis: style;
}


/* ─── scale.css ─── */
/**
 * Radius, spacing and motion.
 *
 * Neither plaza's token layer nor Pi's colorsets carry these — plaza's live as
 * arbitrary Tailwind values scattered through 69 .tsx files (`rounded-[3rem]`,
 * `rounded-[0.625rem]`, `px-4`). Droplets independently grew 40 border-radius
 * declarations across 17 distinct values, which is what an absent scale looks
 * like in practice.
 *
 * The values below are read off bakery's cva maps where one exists (button and
 * chip geometry is quoted at each entry) and rounded to a 4px step elsewhere.
 * Namespaced `--pi-` so it is obvious these are ours, not plaza's.
 */

:root {
  /* Radius — `--pi-radius-pill` is button size=large's rounded-[3rem];
     `--pi-radius-md` is size=medium's rounded-[0.625rem]; `--pi-radius-chip`
     is chip small/badge's rounded-[48px]. */
  --pi-radius-xs: 4px;
  --pi-radius-sm: 8px;
  --pi-radius-md: 10px;
  --pi-radius-lg: 16px;
  --pi-radius-xl: 24px;
  --pi-radius-pill: 48px;
  --pi-radius-chip: 48px;
  --pi-radius-round: 50%;

  /* Spacing — 4px step. `--pi-space-4` (16px) is the button's constant px-4. */
  --pi-space-1: 4px;
  --pi-space-2: 8px;
  --pi-space-3: 12px;
  --pi-space-4: 16px;
  --pi-space-5: 20px;
  --pi-space-6: 24px;
  --pi-space-8: 32px;
  --pi-space-10: 40px;
  --pi-space-12: 48px;

  /* Control heights, from button's cva: h-14 (56px) large, h-10 (40px) medium. */
  --pi-control-lg: 56px;
  --pi-control-md: 40px;
  --pi-control-sm: 32px;

  /* Motion. `transition-colors` in bakery is Tailwind's default 150ms
     cubic-bezier(0.4, 0, 0.2, 1). */
  --pi-dur-fast: 100ms;
  --pi-dur: 150ms;
  --pi-dur-slow: 240ms;
  --pi-ease: cubic-bezier(0.4, 0, 0.2, 1);
}

@media (prefers-reduced-motion: reduce) {
  :root {
    --pi-dur-fast: 0ms;
    --pi-dur: 0ms;
    --pi-dur-slow: 0ms;
  }
}


/* ─── tokens.css (generated) ─── */
/* GENERATED by packages/pi-design/tools/gen-tokens.mjs — do not edit.
 * 99 semantic colours + 7 shadows, light and dark.
 * Source: plaza/apps/bakery/src/design-system/tokens
 * Regenerate: npm --workspace @pi-miniapps/pi-design run gen
 */

:root {
  --color-background: #FAF3EA;
  --color-background-alt: #F7EFE4;
  --color-contrast: #FCFAF7;
  --color-contrast-inverted: #6B6255;
  --color-base-contrast: #FCFAF7;
  --color-text-base-contrast: #FCFAF7;
  --color-text-primary: #1A4631;
  --color-text-primary-strong: #0D3C26;
  --color-text-primary-alt: #038247;
  --color-text-accent: #131212;
  --color-text-secondary: #655E55;
  --color-text-secondary-alt: #6B6255;
  --color-text-tertiary: #A69986;
  --color-text-secondary-base: #655E55;
  --color-text-primary-base: #131212;
  --color-placeholder: #BEB09D;
  --color-base-contrast-inverted: #3E3A35;
  --color-divider-stroke: #E1D3C0;
  --color-split-divider-stroke: #E1D3C0;
  --color-menu-stroke: #E9DDCD;
  --color-message-background: #F3EADE;
  --color-message-text: #131212;
  --color-text-alt-secondary: #077843;
  --color-text-alt-tertiary: #0A6E40;
  --color-input-surface: #FCFAF7;
  --color-input-border: #D9C9B4;
  --color-primary-default: #1A4631;
  --color-primary-hover: #1E3C2E;
  --color-primary-tap: #21322A;
  --color-button-accent-default: #1A4631;
  --color-button-accent-hover: #1E3C2E;
  --color-button-accent-tap: #21322A;
  --color-primary-alt-default: #FCFAF7;
  --color-primary-alt-hover: #FAF3EA;
  --color-primary-alt-tap: #F7EFE4;
  --color-secondary-default: #F5EADC;
  --color-secondary-hover: #E9DDCD;
  --color-secondary-tap: #E4D7C6;
  --color-tertiary-default: #038247;
  --color-tertiary-hover: #077843;
  --color-tertiary-tap: #0A6E40;
  --color-accent-default: #1A4631;
  --color-accent-hover: #1E3C2E;
  --color-accent-tap: #21322A;
  --color-accent-alt-default: #038247;
  --color-accent-alt-hover: #077843;
  --color-accent-alt-tap: #0A6E40;
  --color-button-accent-alt-default: #038247;
  --color-fill-default: #ECE1D2;
  --color-fill-hover: #E9DDCD;
  --color-fill-tap: #E4D7C6;
  --color-button-fill-hover: #E9DDCD;
  --color-button-fill-tap: #E4D7C6;
  --color-fill-alt-default: #FCFAF7;
  --color-fill-alt-hover: #FAF3EA;
  --color-fill-alt-tap: #F7EFE4;
  --color-on-image-default: #FCFAF7;
  --color-on-image-hover: #FAF3EA;
  --color-on-image-tap: #F7EFE4;
  --color-quarternary-default: #A69986;
  --color-quarternary-hover: #9C907E;
  --color-quarternary-tap: #938776;
  --color-error-default: #D42600;
  --color-error-hover: #C72400;
  --color-error-tap: #BB2100;
  --color-disable-stroke: #D9C9B4;
  --color-disabled-fill: #C2B39F;
  --color-button-disabled-fill: #BEB09D;
  --color-default: #FAF3EA;
  --color-default-hover: #F5EEE5;
  --color-default-tap: #F0E9E1;
  --color-edge-stroke: #FFFFFF;
  --color-tile-contrast: #FFFFFF;
  --color-tile-default: #FAF3EA;
  --color-tile-hover: #F7EFE4;
  --color-tile-tap: #F3EADE;
  --color-card-default: #FCFAF7;
  --color-card-hover: #FAF3EA;
  --color-card-tap: #F7EFE3;
  --color-card-edge-stroke: #F5EADC;
  --color-panel-stroke: #E3D4BF;
  --color-feed-feature-intro-graphic: #FAF3EA;
  --color-menu-background: #FCFAF7;
  --color-menu-bg-hover: #FAF3EA;
  --color-menu-bg-tap: #ECE1D2;
  --color-sidebar-logo: #6B6255;
  --color-modal-backlay: rgba(107,98,85,0.36);
  --color-modal-background: #FCFAF7;
  --color-modal-stroke: #E9DDCD;
  --color-top-fade1: #FAF3EA;
  --color-top-fade2: #F5EEDF;
  --color-progress-highlight: #7FC099;
  --color-progress-base: #038247;
  --color-misc-scrollbar: #C2B39F;
  --color-narration-backdrop: rgba(245,234,220,0.2);
  --color-image-overlay-strong: rgba(13,60,38,0.7);
  --color-image-overlay-mid: rgba(13,60,38,0.5);
  --color-skeleton-base: rgba(247,239,227,1);
  --color-skeleton-shimmer: rgba(159,147,120,0.15);

  --shadow-shadow-1: 0px 6px 24px -2px rgba(94, 68, 15, 0.12);
  --shadow-shadow-2: 0px -6px 24px -2px rgba(236, 227, 208, 0.65);
  --shadow-shadow-3: 0px 2px 16px -6px rgba(0, 0, 0, 0.1), 0px 12px 32px -6px #e4dbc8;
  --shadow-shadow-4: 0px 6px 24px -2px rgba(94, 68, 15, 0.12);
  --shadow-toast-shadow: 0px 3px 4px 0px rgba(0, 0, 0, 0.44);
  --shadow-modal-shadow: 0px 2px 16px -6px rgba(0, 0, 0, 0.1), 0px 12px 20px -6px rgba(94, 68, 15, 0.12);
  --shadow-menu-shadow: 0px 2px 16px -6px rgba(0, 0, 0, 0.1), 0px 12px 20px -6px rgba(94, 68, 15, 0.12);
}

:root[data-theme="dark"],
.dark {
  --color-background: #131212;
  --color-background-alt: #1A1918;
  --color-contrast: #1A1918;
  --color-contrast-inverted: #F0EEEB;
  --color-base-contrast: #242322;
  --color-text-base-contrast: #1A1918;
  --color-text-primary: #D8D4CE;
  --color-text-primary-strong: #F7EFE3;
  --color-text-primary-alt: #33C782;
  --color-text-accent: #FCFAF7;
  --color-text-secondary: #B4AFA7;
  --color-text-secondary-alt: #C2B39F;
  --color-text-tertiary: #AAA6A1;
  --color-text-secondary-base: #B4AFA7;
  --color-text-primary-base: #D8D4CE;
  --color-placeholder: #AAA6A1;
  --color-base-contrast-inverted: #F0EEEB;
  --color-divider-stroke: #504A42;
  --color-split-divider-stroke: #595249;
  --color-menu-stroke: #3E3A35;
  --color-message-background: #2D2B29;
  --color-message-text: #D8D4CE;
  --color-text-alt-secondary: #5FB386;
  --color-text-alt-tertiary: #7FC099;
  --color-input-surface: #2C2A28;
  --color-input-border: #4B4844;
  --color-primary-default: #3D9C6E;
  --color-primary-hover: #1E3C2E;
  --color-primary-tap: #21322A;
  --color-button-accent-default: #289A62;
  --color-button-accent-hover: #43A774;
  --color-button-accent-tap: #5FB386;
  --color-primary-alt-default: #1B1917;
  --color-primary-alt-hover: #242322;
  --color-primary-alt-tap: #302E2B;
  --color-secondary-default: #33312E;
  --color-secondary-hover: #4B4844;
  --color-secondary-tap: #56534F;
  --color-tertiary-default: #43A774;
  --color-tertiary-hover: #5FB386;
  --color-tertiary-tap: #7FC099;
  --color-accent-default: #3D9C6E;
  --color-accent-hover: #1E3C2E;
  --color-accent-tap: #21322A;
  --color-accent-alt-default: #43A774;
  --color-accent-alt-hover: #5FB386;
  --color-accent-alt-tap: #7FC099;
  --color-button-accent-alt-default: #43A774;
  --color-fill-default: #413E3B;
  --color-fill-hover: #4B4844;
  --color-fill-tap: #56534F;
  --color-button-fill-hover: #47423B;
  --color-button-fill-tap: #504A42;
  --color-fill-alt-default: #1B1917;
  --color-fill-alt-hover: #242322;
  --color-fill-alt-tap: #302E2B;
  --color-on-image-default: #1B1917;
  --color-on-image-hover: #242322;
  --color-on-image-tap: #302E2B;
  --color-quarternary-default: #AAA6A1;
  --color-quarternary-hover: #B6B3B0;
  --color-quarternary-tap: #C2C0BD;
  --color-error-default: #DE5A3D;
  --color-error-hover: #E1674D;
  --color-error-tap: #E3745C;
  --color-disable-stroke: #5A5753;
  --color-disabled-fill: #787975;
  --color-button-disabled-fill: #AAA6A1;
  --color-default: #242322;
  --color-default-hover: #23211E;
  --color-default-tap: #282622;
  --color-edge-stroke: #47423B;
  --color-tile-contrast: #FFFFFF;
  --color-tile-default: #242322;
  --color-tile-hover: #302E2B;
  --color-tile-tap: #33312E;
  --color-card-default: #242322;
  --color-card-hover: #2D2B29;
  --color-card-tap: #33312E;
  --color-card-edge-stroke: #33312E;
  --color-panel-stroke: #4B4844;
  --color-feed-feature-intro-graphic: #FAF3EA;
  --color-menu-background: #2D2B29;
  --color-menu-bg-hover: #1A1918;
  --color-menu-bg-tap: #1A1918;
  --color-sidebar-logo: #F0EEEB;
  --color-modal-backlay: rgba(45, 43, 41, 0.65);
  --color-modal-background: #1A1918;
  --color-modal-stroke: #3E3A35;
  --color-top-fade1: #131212;
  --color-top-fade2: #131212;
  --color-progress-highlight: #7FC099;
  --color-progress-base: #33C782;
  --color-misc-scrollbar: #787975;
  --color-narration-backdrop: rgba(36,35,34,0.3);
  --color-image-overlay-strong: rgba(0,0,0,0.7);
  --color-image-overlay-mid: rgba(0,0,0,0.5);
  --color-skeleton-base: rgba(51,49,46,1);
  --color-skeleton-shimmer: rgba(200,200,200,0.1);

  --shadow-shadow-1: 0px 6px 24px -2px rgba(0, 0, 0, 0.3);
  --shadow-shadow-2: 0px -6px 24px -2px rgba(19, 18, 18, 0.65);
  --shadow-shadow-3: 0px 2px 16px -6px rgba(0, 0, 0, 0.2), 0px 12px 32px -6px rgba(53, 51, 49, 0.8);
  --shadow-shadow-4: 0px 6px 24px -2px rgba(0, 0, 0, 0.3);
  --shadow-toast-shadow: 0px 3px 4px 0px rgba(0, 0, 0, 0.44);
  --shadow-modal-shadow: 0px 2px 16px -6px rgba(0, 0, 0, 0.2), 0px 12px 20px -6px rgba(0, 0, 0, 0.45);
  --shadow-menu-shadow: 0px 2px 16px -6px rgba(0, 0, 0, 0.1), 0px 12px 20px -6px rgba(0, 0, 0, 0.1);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]):not(.light) {
    --color-background: #131212;
    --color-background-alt: #1A1918;
    --color-contrast: #1A1918;
    --color-contrast-inverted: #F0EEEB;
    --color-base-contrast: #242322;
    --color-text-base-contrast: #1A1918;
    --color-text-primary: #D8D4CE;
    --color-text-primary-strong: #F7EFE3;
    --color-text-primary-alt: #33C782;
    --color-text-accent: #FCFAF7;
    --color-text-secondary: #B4AFA7;
    --color-text-secondary-alt: #C2B39F;
    --color-text-tertiary: #AAA6A1;
    --color-text-secondary-base: #B4AFA7;
    --color-text-primary-base: #D8D4CE;
    --color-placeholder: #AAA6A1;
    --color-base-contrast-inverted: #F0EEEB;
    --color-divider-stroke: #504A42;
    --color-split-divider-stroke: #595249;
    --color-menu-stroke: #3E3A35;
    --color-message-background: #2D2B29;
    --color-message-text: #D8D4CE;
    --color-text-alt-secondary: #5FB386;
    --color-text-alt-tertiary: #7FC099;
    --color-input-surface: #2C2A28;
    --color-input-border: #4B4844;
    --color-primary-default: #3D9C6E;
    --color-primary-hover: #1E3C2E;
    --color-primary-tap: #21322A;
    --color-button-accent-default: #289A62;
    --color-button-accent-hover: #43A774;
    --color-button-accent-tap: #5FB386;
    --color-primary-alt-default: #1B1917;
    --color-primary-alt-hover: #242322;
    --color-primary-alt-tap: #302E2B;
    --color-secondary-default: #33312E;
    --color-secondary-hover: #4B4844;
    --color-secondary-tap: #56534F;
    --color-tertiary-default: #43A774;
    --color-tertiary-hover: #5FB386;
    --color-tertiary-tap: #7FC099;
    --color-accent-default: #3D9C6E;
    --color-accent-hover: #1E3C2E;
    --color-accent-tap: #21322A;
    --color-accent-alt-default: #43A774;
    --color-accent-alt-hover: #5FB386;
    --color-accent-alt-tap: #7FC099;
    --color-button-accent-alt-default: #43A774;
    --color-fill-default: #413E3B;
    --color-fill-hover: #4B4844;
    --color-fill-tap: #56534F;
    --color-button-fill-hover: #47423B;
    --color-button-fill-tap: #504A42;
    --color-fill-alt-default: #1B1917;
    --color-fill-alt-hover: #242322;
    --color-fill-alt-tap: #302E2B;
    --color-on-image-default: #1B1917;
    --color-on-image-hover: #242322;
    --color-on-image-tap: #302E2B;
    --color-quarternary-default: #AAA6A1;
    --color-quarternary-hover: #B6B3B0;
    --color-quarternary-tap: #C2C0BD;
    --color-error-default: #DE5A3D;
    --color-error-hover: #E1674D;
    --color-error-tap: #E3745C;
    --color-disable-stroke: #5A5753;
    --color-disabled-fill: #787975;
    --color-button-disabled-fill: #AAA6A1;
    --color-default: #242322;
    --color-default-hover: #23211E;
    --color-default-tap: #282622;
    --color-edge-stroke: #47423B;
    --color-tile-contrast: #FFFFFF;
    --color-tile-default: #242322;
    --color-tile-hover: #302E2B;
    --color-tile-tap: #33312E;
    --color-card-default: #242322;
    --color-card-hover: #2D2B29;
    --color-card-tap: #33312E;
    --color-card-edge-stroke: #33312E;
    --color-panel-stroke: #4B4844;
    --color-feed-feature-intro-graphic: #FAF3EA;
    --color-menu-background: #2D2B29;
    --color-menu-bg-hover: #1A1918;
    --color-menu-bg-tap: #1A1918;
    --color-sidebar-logo: #F0EEEB;
    --color-modal-backlay: rgba(45, 43, 41, 0.65);
    --color-modal-background: #1A1918;
    --color-modal-stroke: #3E3A35;
    --color-top-fade1: #131212;
    --color-top-fade2: #131212;
    --color-progress-highlight: #7FC099;
    --color-progress-base: #33C782;
    --color-misc-scrollbar: #787975;
    --color-narration-backdrop: rgba(36,35,34,0.3);
    --color-image-overlay-strong: rgba(0,0,0,0.7);
    --color-image-overlay-mid: rgba(0,0,0,0.5);
    --color-skeleton-base: rgba(51,49,46,1);
    --color-skeleton-shimmer: rgba(200,200,200,0.1);

    --shadow-shadow-1: 0px 6px 24px -2px rgba(0, 0, 0, 0.3);
    --shadow-shadow-2: 0px -6px 24px -2px rgba(19, 18, 18, 0.65);
    --shadow-shadow-3: 0px 2px 16px -6px rgba(0, 0, 0, 0.2), 0px 12px 32px -6px rgba(53, 51, 49, 0.8);
    --shadow-shadow-4: 0px 6px 24px -2px rgba(0, 0, 0, 0.3);
    --shadow-toast-shadow: 0px 3px 4px 0px rgba(0, 0, 0, 0.44);
    --shadow-modal-shadow: 0px 2px 16px -6px rgba(0, 0, 0, 0.2), 0px 12px 20px -6px rgba(0, 0, 0, 0.45);
    --shadow-menu-shadow: 0px 2px 16px -6px rgba(0, 0, 0, 0.1), 0px 12px 20px -6px rgba(0, 0, 0, 0.1);
  }
}


/* ─── miniapp-tokens.css ─── */
/* HAND-WRITTEN. The one sheet in this package that declares design tokens and
 * is NOT generated — `tools/gen-tokens.mjs` concatenates it into the barrel and
 * never writes it, which is the whole reason it exists as a separate file.
 *
 * WHAT THIS LAYER IS FOR
 *
 * A mini app sometimes needs a colour that Pi's design system has not defined
 * in code yet. There are three ways to get one and only this one is legal here:
 *
 *   1. Add it to plaza's design system and regenerate. That is a change to PI'S
 *      DESIGN SYSTEM, and a mini app does not make those — the decision on
 *      PI-8941 was "this is a mini app change, you should not make changes to
 *      Pi's design system". A commit that did exactly that was withdrawn; it is
 *      kept in plaza as the tag `pi-8941-tokens-withdrawn` so the reasoning and
 *      the Figma reads are not lost.
 *   2. Hand-edit `css/tokens.css`. That file's header says "do not edit" and
 *      `gen-tokens.mjs --check` exits non-zero on any diff against a fresh
 *      generate, so a hand-edit there is a red build. Correctly.
 *   3. Declare it here, under a name the generator can never emit. That is this
 *      file.
 *
 * THE PREFIX IS THE POINT: `--miniapp-color-*`, NEVER `--color-*`
 *
 * The barrel concatenates this sheet AFTER the generated `tokens.css`, so a
 * `--color-*` declared here would silently OUTRANK the real token the day plaza
 * ships it — that is the exact shape of the stopgap PI-8941 had to unpick, and
 * `test/extension-tokens.test.js` fails on any `--color-*` in this file. The
 * distinct prefix also means the reverse can never happen: nothing upstream
 * emits `--miniapp-*`, so the mirror cannot shadow this either.
 *
 * WHICH LEAVES ONE RISK, AND IT HAS A GUARD
 *
 * The message pair are Pi's semantics, named in Pi's Consumer library for
 * group-chat message bubbles. Droplets is an early consumer, not the owner. So
 * the day Pi adds them for real, this file holds a SECOND source for one
 * designed colour and the two can drift apart with nothing to say so.
 * `test/extension-tokens.test.js` fails the moment the generated mirror emits
 * `--color-<the same suffix>` — i.e. that day is a red build, and the entry
 * below gets deleted deliberately and its consumers repointed, rather than
 * quietly diverging from the design system that owns it.
 *
 * AND ONE ENTRY HERE IS A DIFFERENT KIND OF THING, SAID PLAINLY
 *
 * `--miniapp-color-button-accent-fill` is NOT a stand-in for a token plaza has
 * failed to define. Plaza defines `--color-button-accent-default` and always
 * has. That entry is an OVERRIDE of a value that exists, in dark only, and the
 * collision guard above structurally cannot cover it: the guard fires when
 * upstream STARTS emitting a name, and upstream has been emitting this one all
 * along. Its own guard is in `test/extension-tokens.test.js` and is the other
 * shape — it pins the upstream dark value this diverges FROM, so the day plaza
 * moves it (to the boards' hex or to anything else) the build goes red and
 * somebody decides again, instead of this file quietly overriding a value that
 * no longer needs overriding.
 *
 * The three scopes are the same three `tokens.css` emits, in the same shape and
 * for the same reasons: `[data-theme="dark"]` is the explicit control, the media
 * query is the fallback for a plain browser, and `:not([data-theme="light"])` is
 * what lets an explicit light choice beat a dark OS. A sheet that got this wrong
 * would theme on one path and not the other.
 */

/* ── #message/background-accent + #message/text-contrast ─────────────────────
 *
 * The reader's own message in a group chat: the accent fill and the ink on it.
 * Figma binds them on the live components; plaza's token tree defines neither.
 *
 * VALUES ARE READ OFF FIGMA, NOT DERIVED from the ramp steps either side —
 * file `DA1dmWE9oso2rCNlDLiZNd`:
 *   light  `6401:13615`   background-accent #038247   text-contrast #FCFAF7
 *   dark   `10221:36809`  background-accent #5FB386   text-contrast #242322
 * The dark fill is byte-identical to Figma's own dark frame `6573:13257`.
 *
 * THEY ARE A PAIR AND THEY INVERT TOGETHER. Taking the fill alone makes the
 * surface WORSE, not better: #FBF6EE on #5FB386 is 2.36:1, under the 2.78:1
 * defect it would be trying to fix. As a pair they are 4.70:1 in light and
 * 6.19:1 in dark. `apps/droplets/test/e2e/browser-thread-ink.test.js` asserts
 * that ratio rather than either hex, for exactly that reason.
 */

/* ── the fill under a primary CTA, in dark ───────────────────────────────────
 *
 * NOT A MISSING TOKEN — A DIVERGENCE, AND ONLY IN DARK.
 *
 * `--color-button-accent-default` carries #289A62 in dark. Every dark board in
 * `10221:36433` paints the primary CTA **#3D9C6E** instead. Sampled off the
 * exports at 1:1 rather than taken from the note that asserted it — the pill on
 * `10221:36640`, `10221:36450`, `10221:36646`, `10221:36647`, `10221:36724`,
 * `10221:36896`, `10221:36436` and `10221:36944` is rgb(61,156,110) every time,
 * an unbroken 345x56 run that matches `.btn`'s own `min-height: 56px` and the
 * 393-wide frame's 24px gutters exactly.
 *
 * WHAT THE 0.10 BUYS. The ink on that pill is
 * `--miniapp-color-message-text-contrast` (#242322 in dark, and the boards paint
 * the CTA label rgb(36,35,34) — a 39x12 run dead centre of the pill). On plaza's
 * #289A62 that ink measures **4.399:1**; AA for a 16px label is 4.5:1, so the
 * shipped CTA and the shipped toast are both 0.10 short and have been described
 * as fixed. On the boards' #3D9C6E the same ink is **4.616:1** and AA is met.
 * One value holds both controls down, so one value lifts both.
 *
 * LIGHT DEFERS, AND THAT IS THE POINT OF THE `var()`. The light boards paint the
 * same CTA #1A4631 — byte-identical to `--color-button-accent-default`'s light
 * value — so this app has no independent opinion in light and does not state
 * one. Restating #1A4631 here would be a second copy of a value Pi owns and
 * agrees with us about, i.e. pure drift risk for nothing. Only the scope that
 * actually diverges carries a literal.
 *
 * THE NAME IS WHAT THE COLLISION GUARD LEFT LEGAL, AND IT IS ALSO THE HONEST
 * ONE. The name that would mirror what this overrides is
 * `--miniapp-color-button-accent-default`, and `test/extension-tokens.test.js`
 * REJECTS it: the guard matches on suffix, `--color-button-accent-default`
 * exists upstream, and a token by that name would be exactly the "second,
 * unregenerated copy of a colour Pi owns" the guard is there to stop. That
 * refusal is correct and is not worked around here. `-fill` says the true,
 * narrower thing — the surface a primary CTA is painted with on this app's
 * screens — and pairs with `--miniapp-color-button-accent-label` for the ink on
 * it, the name the standing handover proposes for the half that is still
 * borrowing `--miniapp-color-message-text-contrast`.
 *
 * WHEN TO DELETE THIS. When plaza's dark `--color-button-accent-default` moves.
 * `test/extension-tokens.test.js` pins that upstream value, so that day is a red
 * build rather than a silent no-op override.
 */

/* ── the fill of an avatar nobody has taken, in dark ──────────────────────────
 *
 * SAME SHAPE AS THE CTA ABOVE: an override of a token that exists, in dark only.
 *
 * Figma's waiting seat — `DA1dmWE9oso2rCNlDLiZNd`, `6583:14230` inside the
 * waiting Recents row `6583:14213` — is
 * `fill: var(--button-disabled-fill, #BEB09D)` in light and
 * `fill: var(--button-disabled-fill, #8F877D)` in dark. The generated mirror
 * carries #AAA6A1 in dark, because plaza maps `button-disabled-fill` to the
 * `base-placeholder` primitive and no plaza primitive holds #8F877D. iOS's own
 * `button/disabled_fill` colorset is #8F877D, i.e. Figma's; the generated value
 * is the one that is wrong, and plaza is not this app's to change (PI-8941's
 * ruling, restated by Wei on 2026-09-07).
 *
 * LIGHT DEFERS, for the CTA's reason: Figma's light value IS the generated one,
 * byte for byte, so this app has no independent opinion in light and does not
 * state one.
 *
 * WHEN TO DELETE THIS: when plaza's dark `--color-button-disabled-fill` moves.
 * `test/extension-tokens.test.js` pins that upstream value, so that day is a red
 * build rather than a silent no-op override.
 */

/* ── the letter on a seat tint ────────────────────────────────────────────────
 *
 * `#neutrals/dark/charcoal-50`, the primitive iOS pins `UserAvatar` to
 * (`charcoal_50.colorset`, one universal entry). Read by
 * `.pi-avatar[data-ink="seat"]` and nothing else.
 *
 * NEAREST PLAZA TOKEN IS `--color-text-accent`, AND IT CANNOT CARRY THIS: same
 * hex in light, #FCFAF7 in dark, i.e. it themes — which is what the designer's
 * 2026-09-02 ruling reverses. No `--color-seat-ink` upstream, so the collision
 * guard passes today and fires the day plaza defines one.
 *
 * THE DARK VALUE IS THE LIGHT VALUE. droplets stated mode-invariance by
 * declaring the ink once; this layer must declare the same names in light and
 * dark, so here it is an equal value, pinned by `test/pi-avatar.test.js`.
 */

/* ── the twenty seat fills ────────────────────────────────────────────────────
 *
 * Figma `h4c5aqumP5IpXMlF1sqPva` `10083:27781` — the Avatar set's variants A–T,
 * a bloom/glow/ocean/nebula cycle five times over. THE ORDER IS THE TOKEN:
 * PI-9183 hands the nth person a viewer meets the nth fill, so `-01` is a
 * position in that sequence and not a ramp step. The Figma name each carries:
 *
 *   -01 bloom-300
 *   -02 glow-200
 *   -03 ocean-100
 *   -04 nebula-250
 *   -05 bloom-150
 *   -06 glow-300
 *   -07 ocean-200
 *   -08 nebula-100
 *   -09 bloom-250
 *   -10 glow-150
 *   -11 ocean-300
 *   -12 nebula-200
 *   -13 bloom-100
 *   -14 glow-250
 *   -15 ocean-150
 *   -16 nebula-300
 *   -17 bloom-200
 *   -18 glow-100
 *   -19 ocean-250
 *   -20 nebula-150
 *
 * DECLARED THREE TIMES AT ONE VALUE, like `--miniapp-color-seat-ink` above:
 * these fills do NOT theme (the dark board paints the same twenty and changes
 * only the ring), and this layer must declare the same names in light and dark
 * — `test/extension-tokens.test.js` refuses a name present in one scope only.
 *
 * NOT `--pi-tone-*`. That is PI-8941's five-tone ramp in `css/avatar.css`:
 * same four family names, different values, different count.
 *
 * THE READER IS JAVASCRIPT, NOT CSS. Every avatar takes its fill as an inline
 * hex, because the same colour has to travel to Pi on the members patch, where
 * a `var()` means nothing. So the twenty live here as the NAMED record and
 * `apps/droplets/test/unit/seat-colours.test.js` pins the app's three literals
 * against them, in order — that test is what makes this block load-bearing.
 */

/* ── the bottom sheet's backdrop and its handle ───────────────────────────────
 *
 * Read by `css/sheet.css` and nothing else.
 *
 * NEAREST PLAZA TOKEN FOR THE BACKDROP IS `--color-modal-backlay`, AND IT IS A
 * DIFFERENT COLOUR: rgba(107,98,85,0.36) / rgba(45,43,41,0.65) against these.
 * It is the CENTRED dialog's scrim — `.pi-dialog-scrim` spends it — and a
 * bottom sheet's is darker and neutral in both schemes. The handle has no
 * near-miss upstream at all. No `--color-scrim` and no `--color-grab`, so the
 * collision guard passes today and fires the day plaza defines either.
 */

/* ── the anchored popovers' four neutrals ────────────────────────────────────
 *
 * Read by `css/press.css` and by the `.pi-menu[data-platform]` arms in
 * `css/components.css`. Every one of them is a NEAR-MISS on something plaza
 * has, in one scheme only, which is the shape that ships green and looks wrong
 * in the other:
 *
 *   -field   #FFFFFF / #2D2B29   `--color-menu-background` agrees in DARK and
 *                                is #FCFAF7 in light; `--color-edge-stroke`
 *                                agrees in LIGHT and is #47423B in dark.
 *   -border  #EBE3D5 / #595249   `--color-menu-stroke` is #E9DDCD / #3E3A35 —
 *                                two shades out in both.
 *   -shadow  rgba(14,14,12,.16) / rgba(0,0,0,.55)   plaza publishes finished
 *                                `box-shadow` strings, never the ink alone.
 *   -tap     #F5EFE5 / #2D2B29   `--color-fill-tap` is #E4D7C6 / #56534F.
 *
 * None of `--color-field`, `--color-border`, `--color-shadow`, `--color-tap`
 * exists upstream, so the collision guard passes today and fires the day plaza
 * defines any of them. `test/press.test.js` pins each near-miss as NOT equal,
 * so the day one converges somebody takes the upstream token instead.
 *
 * `-tap` AND `-field` ARE THE SAME HEX IN DARK AND THAT IS LOAD-BEARING, not a
 * duplicate: it is why the emoji bar's selected state is a menu fill rather
 * than the pressed one, which would be invisible on its own plate.
 */

/* ── the controls' four neutrals ─────────────────────────────────────────────
 *
 * Read by the `[data-size="cta"]`, `[data-size="disc"]`, `[data-variant="app"]`
 * arms in `css/components.css` and by `css/controls.css`. Same shape as the
 * four above — each is a near-miss on a plaza token in one scheme only:
 *
 *   -ink         #3E3A35 / #FCFAF7   `--color-base-contrast-inverted` agrees in
 *                                    LIGHT and is #F0EEEB in dark;
 *                                    `--color-text-accent` agrees in DARK and is
 *                                    #131212 in light.
 *   -muted       #6B6258 / #B4AFA7   `--color-text-secondary` agrees in DARK and
 *                                    is #655E55 in light.
 *   -tile        #FBF6EE / #2D2B29   three tokens agree in DARK
 *                                    (`--color-message-background`,
 *                                    `--color-card-hover`,
 *                                    `--color-menu-background`); NONE agrees in
 *                                    light.
 *   -button-off  #B7C0B3 / #3A4A40   no near-miss in either scheme. It is the
 *                                    iOS `Button.disabled_fill` colorset, and
 *                                    the dark value deliberately diverges from
 *                                    the frames: their #1F3B2E takes the dark
 *                                    disabled label from 1.67:1 to 1.29:1.
 *
 * None of `--color-ink`, `--color-muted`, `--color-tile`, `--color-button-off`
 * exists upstream. `--color-tile-default/-hover/-contrast` do — different whole
 * tokens, and the guard matches the whole suffix, so they are not a collision.
 * `test/controls.test.js` pins each near-miss as NOT equal.
 */

/* ── the flat surfaces' four ──────────────────────────────────────────────────
 *
 * Read by `css/surfaces.css`, `css/preview.css`, `css/person-row.css` and the
 * `[data-variant="app"]` arms in `css/components.css`. `-muted` is NOT declared
 * again here — it is the controls' entry above, at the same two values, and one
 * designed colour gets one declaration.
 *
 *   -badge-stroke   #E2CDB4 / #595249   the hairline round a pill of metadata.
 *                   `--color-split-divider-stroke` agrees in DARK only
 *                   (#E1D3C0 in light).
 *   -amber-*        fill #FCEFD9 / #3A2E1C, stroke #F0D9AE / #5C4A2C,
 *                   ink #B5701A / #E9B872. A whole three-colour warning palette
 *                   with NO near-miss upstream in either scheme: plaza publishes
 *                   error red and accent green, and nothing amber at all.
 *
 * None of `--color-badge-stroke`, `--color-amber-fill`, `--color-amber-stroke`,
 * `--color-amber-ink` exists upstream, so the collision guard passes today and
 * fires the day plaza defines any of them. `test/surfaces.test.js` pins each
 * near-miss as NOT equal, so the day one converges somebody takes the upstream
 * token instead.
 */

:root {
  --miniapp-color-message-background-accent: #038247;
  --miniapp-color-message-text-contrast: #FCFAF7;
  --miniapp-color-button-accent-fill: var(--color-button-accent-default);
  --miniapp-color-avatar-pending-fill: var(--color-button-disabled-fill);
  --miniapp-color-seat-ink: #131212;
  --miniapp-color-seat-fill-01: #D597A7;
  --miniapp-color-seat-fill-02: #E6CCBA;
  --miniapp-color-seat-fill-03: #D1D7DC;
  --miniapp-color-seat-fill-04: #D5B6D7;
  --miniapp-color-seat-fill-05: #E9CACC;
  --miniapp-color-seat-fill-06: #D0A287;
  --miniapp-color-seat-fill-07: #B2C1D2;
  --miniapp-color-seat-fill-08: #F3E8E7;
  --miniapp-color-seat-fill-09: #DDACB7;
  --miniapp-color-seat-fill-10: #EBD7C7;
  --miniapp-color-seat-fill-11: #96AEC9;
  --miniapp-color-seat-fill-12: #E2CCDE;
  --miniapp-color-seat-fill-13: #F0DBD9;
  --miniapp-color-seat-fill-14: #DBB8A1;
  --miniapp-color-seat-fill-15: #C2CCD7;
  --miniapp-color-seat-fill-16: #C9A2D1;
  --miniapp-color-seat-fill-17: #E2B7BF;
  --miniapp-color-seat-fill-18: #F2E4D8;
  --miniapp-color-seat-fill-19: #A0B5CC;
  --miniapp-color-seat-fill-20: #EAD8E2;
  --miniapp-color-scrim: rgba(14,14,12,.28);
  --miniapp-color-grab: #D8CEBE;
  --miniapp-color-field: #FFFFFF;
  --miniapp-color-border: #EBE3D5;
  --miniapp-color-shadow: rgba(14,14,12,.16);
  --miniapp-color-tap: #F5EFE5;
  --miniapp-color-ink: #3E3A35;
  --miniapp-color-muted: #6B6258;
  --miniapp-color-tile: #FBF6EE;
  --miniapp-color-button-off: #B7C0B3;
  --miniapp-color-badge-stroke: #E2CDB4;
  --miniapp-color-amber-fill: #FCEFD9;
  --miniapp-color-amber-stroke: #F0D9AE;
  --miniapp-color-amber-ink: #B5701A;
}

:root[data-theme="dark"],
.dark {
  --miniapp-color-message-background-accent: #5FB386;
  --miniapp-color-message-text-contrast: #242322;
  --miniapp-color-button-accent-fill: #3D9C6E;
  --miniapp-color-avatar-pending-fill: #8F877D;
  --miniapp-color-seat-ink: #131212;
  --miniapp-color-seat-fill-01: #D597A7;
  --miniapp-color-seat-fill-02: #E6CCBA;
  --miniapp-color-seat-fill-03: #D1D7DC;
  --miniapp-color-seat-fill-04: #D5B6D7;
  --miniapp-color-seat-fill-05: #E9CACC;
  --miniapp-color-seat-fill-06: #D0A287;
  --miniapp-color-seat-fill-07: #B2C1D2;
  --miniapp-color-seat-fill-08: #F3E8E7;
  --miniapp-color-seat-fill-09: #DDACB7;
  --miniapp-color-seat-fill-10: #EBD7C7;
  --miniapp-color-seat-fill-11: #96AEC9;
  --miniapp-color-seat-fill-12: #E2CCDE;
  --miniapp-color-seat-fill-13: #F0DBD9;
  --miniapp-color-seat-fill-14: #DBB8A1;
  --miniapp-color-seat-fill-15: #C2CCD7;
  --miniapp-color-seat-fill-16: #C9A2D1;
  --miniapp-color-seat-fill-17: #E2B7BF;
  --miniapp-color-seat-fill-18: #F2E4D8;
  --miniapp-color-seat-fill-19: #A0B5CC;
  --miniapp-color-seat-fill-20: #EAD8E2;
  --miniapp-color-scrim: rgba(0,0,0,.55);
  --miniapp-color-grab: #595249;
  --miniapp-color-field: #2D2B29;
  --miniapp-color-border: #595249;
  --miniapp-color-shadow: rgba(0,0,0,.55);
  --miniapp-color-tap: #2D2B29;
  --miniapp-color-ink: #FCFAF7;
  --miniapp-color-muted: #B4AFA7;
  --miniapp-color-tile: #2D2B29;
  --miniapp-color-button-off: #3A4A40;
  --miniapp-color-badge-stroke: #595249;
  --miniapp-color-amber-fill: #3A2E1C;
  --miniapp-color-amber-stroke: #5C4A2C;
  --miniapp-color-amber-ink: #E9B872;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]):not(.light) {
    --miniapp-color-message-background-accent: #5FB386;
    --miniapp-color-message-text-contrast: #242322;
    --miniapp-color-button-accent-fill: #3D9C6E;
    --miniapp-color-avatar-pending-fill: #8F877D;
    --miniapp-color-seat-ink: #131212;
    --miniapp-color-seat-fill-01: #D597A7;
    --miniapp-color-seat-fill-02: #E6CCBA;
    --miniapp-color-seat-fill-03: #D1D7DC;
    --miniapp-color-seat-fill-04: #D5B6D7;
    --miniapp-color-seat-fill-05: #E9CACC;
    --miniapp-color-seat-fill-06: #D0A287;
    --miniapp-color-seat-fill-07: #B2C1D2;
    --miniapp-color-seat-fill-08: #F3E8E7;
    --miniapp-color-seat-fill-09: #DDACB7;
    --miniapp-color-seat-fill-10: #EBD7C7;
    --miniapp-color-seat-fill-11: #96AEC9;
    --miniapp-color-seat-fill-12: #E2CCDE;
    --miniapp-color-seat-fill-13: #F0DBD9;
    --miniapp-color-seat-fill-14: #DBB8A1;
    --miniapp-color-seat-fill-15: #C2CCD7;
    --miniapp-color-seat-fill-16: #C9A2D1;
    --miniapp-color-seat-fill-17: #E2B7BF;
    --miniapp-color-seat-fill-18: #F2E4D8;
    --miniapp-color-seat-fill-19: #A0B5CC;
    --miniapp-color-seat-fill-20: #EAD8E2;
    --miniapp-color-scrim: rgba(0,0,0,.55);
    --miniapp-color-grab: #595249;
    --miniapp-color-field: #2D2B29;
    --miniapp-color-border: #595249;
    --miniapp-color-shadow: rgba(0,0,0,.55);
    --miniapp-color-tap: #2D2B29;
    --miniapp-color-ink: #FCFAF7;
    --miniapp-color-muted: #B4AFA7;
    --miniapp-color-tile: #2D2B29;
    --miniapp-color-button-off: #3A4A40;
    --miniapp-color-badge-stroke: #595249;
    --miniapp-color-amber-fill: #3A2E1C;
    --miniapp-color-amber-stroke: #5C4A2C;
    --miniapp-color-amber-ink: #E9B872;
  }
}


/* ─── components.css ─── */
/**
 * Pi components for plain HTML.
 *
 * Geometry and state colours are transcribed from bakery's own cva maps — the
 * source file and variant key is quoted above each block, so a reviewer can
 * diff this against upstream by hand. Nothing here is invented where an
 * upstream value exists.
 *
 * Variants are DATA ATTRIBUTES, not modifier classes:
 *
 *     <button class="pi-btn" data-variant="primary" data-size="large">Send</button>
 *
 * because cva's compoundVariants are precedence rules, and CSS specificity
 * already implements exactly that: `.pi-btn[data-variant="x"]` is (0,2,0) and
 * `.pi-btn[data-variant="x"][data-size="y"]` is (0,3,0), so a compound wins
 * over its parts for free. It also interpolates cleanly into the innerHTML
 * templating mini apps use.
 *
 * Everything is additive and inert until used — an app that links this file and
 * uses none of it renders exactly as before.
 *
 * NOT included: typography utilities (see type-scale.css for why), icons
 * (bakery gets them through @svgr/webpack, which cannot survive a no-build
 * port), and anything requiring React state.
 */

/* ══════════════════════════════════════════════════════════════ button
 * bakery: components/buttons/button.tsx — buttonVariants cva
 * base: inline-flex items-center justify-center whitespace-nowrap
 *       transition-colors disabled:pointer-events-none disabled:opacity-50
 */

.pi-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  white-space: nowrap;
  gap: 6px; /* gap-1.5, all sizes */
  border: 0;
  cursor: pointer;
  text-decoration: none;
  font-family: var(--pi-font-body);
  transition: background-color var(--pi-dur) var(--pi-ease),
    color var(--pi-dur) var(--pi-ease), border-color var(--pi-dur) var(--pi-ease);
}

.pi-btn:disabled,
.pi-btn[aria-disabled="true"] {
  pointer-events: none;
  opacity: 0.5;
}

/* — sizes ————————————————————————————————————————————————————— */

/* size=large: h-14 w-full min-w-32 max-w-[22.0625rem] rounded-[3rem] px-4 */
.pi-btn[data-size="large"] {
  height: var(--pi-control-lg);
  width: 100%;
  min-width: 128px;
  max-width: 22.0625rem;
  padding-inline: var(--pi-space-4);
  border-radius: var(--pi-radius-pill);
  font-size: 1.125rem; /* Action-M 18px */
  line-height: var(--pi-lh-copy);
  letter-spacing: var(--pi-track-tight);
  font-weight: 500;
}

/* size=medium: h-10 w-fit min-w-24 rounded-[0.625rem] px-4 */
.pi-btn[data-size="medium"] {
  height: var(--pi-control-md);
  width: fit-content;
  min-width: 96px;
  padding-inline: var(--pi-space-4);
  border-radius: var(--pi-radius-md);
  font-size: 1rem; /* Action-S 16px */
  line-height: var(--pi-lh-copy);
  letter-spacing: var(--pi-track-body);
  font-weight: 500;
}

/* size=large-tertiary: h-14 w-fit rounded-[3rem] px-4 antialiased */
.pi-btn[data-size="large-tertiary"] {
  height: var(--pi-control-lg);
  width: fit-content;
  padding-inline: var(--pi-space-4);
  border-radius: var(--pi-radius-pill);
  font-size: 1.125rem;
  line-height: var(--pi-lh-copy);
  letter-spacing: var(--pi-track-tight);
  font-weight: 500;
  -webkit-font-smoothing: antialiased;
}

/* — variants —————————————————————————————————————————————————— */

/* variant=primary: !bg-button-accent-default text-base-contrast */
.pi-btn[data-variant="primary"] {
  background: var(--color-button-accent-default);
  color: var(--color-base-contrast);
}
.pi-btn[data-variant="primary"]:hover {
  background: var(--color-button-accent-hover);
}
.pi-btn[data-variant="primary"]:active {
  background: var(--color-button-accent-tap);
}
/* compound {variant:primary}: disabled is a FILL not an opacity-only fade —
   disabled:!bg-button-accent-hover + !opacity-30, label stays base-contrast. */
.pi-btn[data-variant="primary"]:disabled,
.pi-btn[data-variant="primary"][aria-disabled="true"] {
  background: var(--color-button-accent-hover);
  color: var(--color-base-contrast);
  opacity: 0.3;
}
/* compound {variant:primary, size:medium}: hover/active keep the 10px radius */
.pi-btn[data-variant="primary"][data-size="medium"]:hover,
.pi-btn[data-variant="primary"][data-size="medium"]:active {
  border-radius: var(--pi-radius-md);
}

/* variant=secondary: border border-divider-stroke bg-background text-text-secondary */
.pi-btn[data-variant="secondary"] {
  border: 1px solid var(--color-divider-stroke);
  background: var(--color-background);
  color: var(--color-text-secondary);
}
.pi-btn[data-variant="secondary"]:hover {
  background: var(--color-secondary-hover);
}
.pi-btn[data-variant="secondary"]:active {
  background: var(--color-secondary-tap);
}
/* compound {variant:secondary, size:medium}: bg-transparent */
.pi-btn[data-variant="secondary"][data-size="medium"] {
  background: transparent;
}

/* variant=ghost / primary-alt: bg-primary-alt-default text-text-secondary */
.pi-btn[data-variant="ghost"],
.pi-btn[data-variant="primary-alt"] {
  background: var(--color-primary-alt-default);
  color: var(--color-text-secondary);
}
.pi-btn[data-variant="primary-alt"] {
  border: 1px solid var(--color-divider-stroke);
}
.pi-btn[data-variant="ghost"]:hover,
.pi-btn[data-variant="primary-alt"]:hover {
  background: var(--color-primary-alt-hover);
}
.pi-btn[data-variant="ghost"]:active,
.pi-btn[data-variant="primary-alt"]:active {
  background: var(--color-primary-alt-tap);
}
/* compounds {ghost|primary-alt, medium}: bg-transparent */
.pi-btn[data-variant="ghost"][data-size="medium"],
.pi-btn[data-variant="primary-alt"][data-size="medium"] {
  background: transparent;
}

/* variant=tertiary: bg-transparent text-tertiary-default */
.pi-btn[data-variant="tertiary"] {
  background: transparent;
  color: var(--color-tertiary-default);
}
.pi-btn[data-variant="tertiary"]:hover {
  color: var(--color-tertiary-hover);
}
.pi-btn[data-variant="tertiary"]:active {
  color: var(--color-tertiary-tap);
}

/* variant=negative: bg-error-default text-base-contrast */
.pi-btn[data-variant="negative"] {
  background: var(--color-error-default);
  color: var(--color-base-contrast);
}
.pi-btn[data-variant="negative"]:hover {
  background: var(--color-error-hover);
}
.pi-btn[data-variant="negative"]:active {
  background: var(--color-error-tap);
}
/* compound {negative, large}: px-6 */
.pi-btn[data-variant="negative"][data-size="large"] {
  padding-inline: var(--pi-space-6);
}
/* compound {negative, medium}: outlined, not filled */
.pi-btn[data-variant="negative"][data-size="medium"] {
  border: 1px solid var(--color-error-default);
  background: transparent;
  color: var(--color-error-default);
}
.pi-btn[data-variant="negative"][data-size="medium"]:hover {
  border-color: var(--color-error-hover);
  background: transparent;
  color: var(--color-error-hover);
}
.pi-btn[data-variant="negative"][data-size="medium"]:active {
  border-color: var(--color-error-tap);
  background: transparent;
  color: var(--color-error-tap);
}

/* variant=on-image: for overlays on photography */
.pi-btn[data-variant="on-image"] {
  background: var(--color-on-image-default);
  color: var(--color-text-secondary);
  box-shadow: var(--shadow-shadow-1);
}
.pi-btn[data-variant="on-image"]:hover {
  background: var(--color-on-image-hover);
}
.pi-btn[data-variant="on-image"]:active {
  background: var(--color-on-image-tap);
}

/* — size=cta: Droplets' full-width pill ————————————————————————————
 *
 * A VARIANT, not a change to `.pi-btn`: `[data-size="large"]` above is bakery's
 * h-14 / max-w-22.0625rem control and stays exactly as it was. This one is the
 * reviewed Between Us CTA — uncapped, `min-height` rather than `height`, and a
 * 100px pill.
 *
 * It restates every property the base `.pi-btn` declares EXCEPT `transition`,
 * and that exception is deliberate: the consuming app names the legacy class at
 * (0,1,0) in both its pressed-state block and its `prefers-reduced-motion`
 * block, and a `transition` here at (0,2,0) would silently defeat both. The base
 * cannot leak either way — the app's own (0,1,0) rule already ties it and wins
 * on order.
 */
.pi-btn[data-size="cta"] {
  display: flex;
  align-items: center;
  justify-content: center;
  /* Both undone from the base: this pill wraps a long label, and the 6px gap
     would double the 5px margin `.btn svg` already spends on the one screen
     that draws a glyph beside the word. */
  white-space: normal;
  gap: 0;
  width: 100%;
  min-height: 56px;
  padding: 8px 15px;
  border: none;
  border-radius: 100px;
  background: var(--miniapp-color-button-accent-fill);
  color: var(--miniapp-color-message-text-contrast);
  font-family: inherit;
  font-size: 16px;
  font-weight: 500;
  letter-spacing: var(--pi-track-body);
  line-height: var(--pi-lh-single);
  margin-top: 6px;
  /* The base draws a pointer; a `<button>`'s own UA cursor is `default` and that
     is what these screens were reviewed with. */
  cursor: auto;
  text-decoration: none;
}

/* DEAD IS A FILL, NOT A FADE. The base's `opacity: .5` cannot express this: half
   these CTAs are direct children of a container whose swap animation has
   `fill-mode: both` and pins opacity at 1, so a fade would apply to some dead
   buttons and not others. The lever is the fill. */
.pi-btn[data-size="cta"]:disabled,
.pi-btn[data-size="cta"][aria-disabled="true"] {
  background: var(--miniapp-color-button-off);
  opacity: 1;
  pointer-events: auto;
}

/* AND BUSY IS NOT DEAD. A control posting its form is `disabled` and holds
   `.pi-dots` in place of its label; those discs take a fixed ink and never
   `currentColor`, or in dark they are near-black on a near-black pill while the
   request retries. The one rule in this file whose subject belongs to another
   sheet, and it is here because the button is what decides it. */
.pi-btn[data-size="cta"]:disabled .pi-dots i {
  background: var(--color-text-accent, #131212);
}

/* THE RING OWES THE SAME INK, and assuming otherwise is a defect this rule was written
   twice to close. `.pi-spinner` takes both tones from `currentColor` — which is the right
   default, and is wrong for exactly this element, because a busy CTA does not re-ink: the
   fill goes to `--miniapp-color-button-off` (a light sage) while `color` stays the pill's
   near-white label ink. Measured on this stylesheet at 420px, both themes: fill
   rgb(183,192,179) light / rgb(182,191,178) dark under an arc of rgb(252,250,247) — about
   1.7:1, a ring you cannot see on the one state it exists for. The discs above are legible
   only because this rule already says so.

   BORDER-COLOR BEFORE BORDER-TOP-COLOR, and `color-mix` on the named token rather than on
   `currentColor`, so the track keeps the 25% relationship the component declares instead of
   re-deriving it from ink this rule has just overridden. */
.pi-btn[data-size="cta"]:disabled .pi-spinner {
  border-color: color-mix(in srgb, var(--color-text-accent, #131212) 25%, transparent);
  border-top-color: var(--color-text-accent, #131212);
}

/* ══════════════════════════════════════════════════════════════ icon button */

.pi-icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: var(--pi-control-md);
  height: var(--pi-control-md);
  padding: 0;
  border: 0;
  border-radius: var(--pi-radius-round);
  background: var(--color-fill-default);
  color: var(--color-text-secondary);
  cursor: pointer;
  transition: background-color var(--pi-dur) var(--pi-ease);
}
.pi-icon-btn:hover {
  background: var(--color-fill-hover);
}
.pi-icon-btn:active {
  background: var(--color-fill-tap);
}
.pi-icon-btn[data-variant="bare"] {
  background: transparent;
}
.pi-icon-btn[data-size="small"] {
  width: var(--pi-control-sm);
  height: var(--pi-control-sm);
}
.pi-icon-btn:disabled {
  pointer-events: none;
  opacity: 0.5;
}

/* — size=disc: Droplets' 34px round control ————————————————————————
 *
 * Smaller than either DS size (40 / 32) and on `--color-contrast` rather than
 * `--color-fill-default`, so it is its own arm rather than a `[data-size]` the
 * scale already has. `transition` is not restated, for the reason given on
 * `[data-size="cta"]`.
 *
 * WHAT IS NOT HERE, AND IT COST A ROUND OF E2E TO LEARN: the transparent plate. Written as
 * `[data-size="disc"][data-variant="bare"]` it reaches (0,3,0), which outranks a consumer's
 * own `:active` fill at (0,2,0) — so a bare control silently stopped darkening under a finger
 * while drawing correctly in every screenshot. A modifier whose only job is to undo this arm's
 * fill has to stay at the consumer's depth, beside the press it must not outrank.
 *
 * ALSO NOT HERE: where the disc SITS. A host that pins one into a header
 * grows it, lifts it onto the platform's own chrome band and paints its
 * material over it — all of which are reads of `--pi-chrome-*` and of the host's
 * safe area, which this package cannot know. The look is the component's; the
 * placement stays with the app.
 */
.pi-icon-btn[data-size="disc"] {
  display: grid;
  place-items: center;
  /* Back to its initial: the base centres a flex row, and a one-track grid
     centres from `place-items` instead. */
  justify-content: normal;
  flex: 0 0 auto;
  width: 34px;
  height: 34px;
  /* WebKit's UA button padding is ASYMMETRIC, so the glyph centres 0.56px above
     the circle it sits in — invisible in Chromium, which pads evenly. */
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--color-contrast);
  color: var(--color-text-secondary);
  cursor: auto;
  position: relative;
}

/* 34 is the ring the eye reads; 44 is what a thumb needs. A pseudo-element, so
   the hit target grows and the disc does not. */
.pi-icon-btn[data-size="disc"]::after {
  content: "";
  position: absolute;
  inset: -5px;
  border-radius: 50%;
}

/* This disc has never faded when disabled. */
.pi-icon-btn[data-size="disc"]:disabled {
  opacity: 1;
  pointer-events: auto;
}

/* ══════════════════════════════════════════════════════════════ chip / badge
 * bakery: components/chips/chip.tsx — chipVariants cva
 */

.pi-chip {
  display: inline-flex;
  align-items: center;
  white-space: nowrap;
  min-width: 0;
  max-width: 100%;
  border: 1px solid var(--color-divider-stroke);
  background: transparent;
  color: var(--color-text-secondary);
  font-family: var(--pi-font-body);
  transition: background-color var(--pi-dur) var(--pi-ease),
    color var(--pi-dur) var(--pi-ease);
}

/* variant=badge — Consumer Library `Badge` (Figma 183:1009):
 * column, centered, gap 6px; padding 4×8px; pill; Label/XS; text-secondary-base
 */
.pi-chip[data-variant="badge"] {
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  overflow: hidden;
  text-overflow: ellipsis;
  padding: var(--pi-space-1) var(--pi-space-2);
  border-radius: var(--pi-radius-chip);
  background: var(--color-default);
  color: var(--color-text-secondary-base);
  font-size: 0.75rem; /* Label-XS 12px */
  line-height: var(--pi-lh-single);
  letter-spacing: var(--pi-track-body);
  font-weight: 500;
}
.pi-chip[data-variant="badge"]:hover {
  background: var(--color-button-fill-hover);
}
.pi-chip[data-variant="badge"]:active {
  background: var(--color-button-fill-tap);
}

/* variant=small — pill with border, px-12 py-8 */
.pi-chip[data-variant="small"] {
  padding: var(--pi-space-2) var(--pi-space-3);
  border-radius: var(--pi-radius-chip);
  font-size: 0.875rem;
  line-height: var(--pi-lh-single);
  letter-spacing: var(--pi-track-body);
}
.pi-chip[data-variant="small"]:active,
.pi-chip[data-variant="small"][data-state="active"] {
  background: var(--color-secondary-tap);
  color: var(--color-text-primary);
}

/* variant=large — h-56 rounded-10 px-24, used for selectors */
.pi-chip[data-variant="large"] {
  height: var(--pi-control-lg);
  width: 100%;
  max-width: 22.0625rem;
  padding: 0 var(--pi-space-6);
  border-radius: var(--pi-radius-md);
  background: var(--color-background);
}
.pi-chip[data-variant="large"][data-state="selected"] {
  justify-content: space-between;
  padding-right: 18px;
  background: var(--color-secondary-tap);
  color: var(--color-text-primary-base);
}

.pi-chip:disabled,
.pi-chip[data-state="disabled"] {
  cursor: not-allowed;
  border-color: transparent;
  background: var(--color-background);
  color: var(--color-disable-stroke);
}

/* — variant=app: Droplets' filled pill ————————————————————————————
 *
 * Same box as `[data-variant="small"]` — 8/12 padding on a 48px pill — and a
 * different skin: a fill rather than a transparent plate, and Body/M rather than
 * 14px. It is declared AFTER `[data-state="disabled"]`, so a chip wearing both
 * keeps this arm's fill; nothing in this file's callers writes that state.
 * `transition` is not restated, for the reason given on `[data-size="cta"]`.
 */
.pi-chip[data-variant="app"] {
  display: inline-flex;
  align-items: center;
  /* All three undone from the base: these pills sit in a wrapping row, where a
     label breaks over two lines rather than being clipped, and no pill is
     measured against its container. */
  white-space: normal;
  min-width: auto;
  max-width: none;
  padding: 8px 12px;
  border: 1px solid var(--miniapp-color-border);
  border-radius: 48px;
  background: var(--miniapp-color-tile);
  color: var(--miniapp-color-muted);
  font-family: inherit;
  font-size: 16px;
  letter-spacing: var(--pi-track-body);
  line-height: 1.1;
  cursor: auto;
}

/* ══════════════════════════════════════════════════════════════ card
 * `.pi-card` only. ImageTile (`.pi-tile`) used to sit under this banner and now
 * owns `css/image-tile.css`, alongside avatar, chat card, conversation and
 * reactions — each a file, each the sole owner of its selectors. This file
 * keeps the primitives that never grew that big.
 */

.pi-card {
  display: block;
  background: var(--color-card-default);
  border-radius: var(--pi-radius-lg);
  overflow: hidden;
}
.pi-card[data-interactive] {
  cursor: pointer;
  transition: background-color var(--pi-dur) var(--pi-ease);
}
.pi-card[data-interactive]:hover {
  background: var(--color-card-hover);
}
.pi-card[data-interactive]:active {
  background: var(--color-card-tap);
}

/* Between Us's own card: a stroked panel with its padding built in. A VARIANT and not a
   change to the default above, which is Pi's own bare surface and stays exactly as it was.
   It is SELF-CONTAINED — it restates every property the default declares, including
   `overflow: visible`, because a variant that lets one through inherits a clip this card
   never had. `16px` as a literal rather than `--pi-radius-lg` (the same number today): this
   arm is pinned to a reviewed value and must not follow a scale change on its own. */
.pi-card[data-variant="app"] {
  display: block;
  background: var(--color-contrast);
  border: 1px solid var(--miniapp-color-border);
  border-radius: 16px;
  padding: 15px;
  margin-bottom: 12px;
  overflow: visible;
}

/* ══════════════════════════════════════════════════════════════ message
 * PI-8941 item 1 — #message/background-accent + #message/text-contrast, the
 * surface and the ink of the current user's own message in a group chat.
 *
 * They come from `css/miniapp-tokens.css`, NOT from the generated mirror, and
 * they are therefore spelled `--miniapp-color-*`. plaza's design system does
 * not define this pair, and a mini app does not change Pi's design system to
 * get one — so the values are read off Figma into a layer this package owns.
 * That file's header carries the node ids and the guard that fires the day
 * upstream ships them for real.
 *
 * They were briefly declared right here instead, aliased to `accent-alt-default`
 * and `neutral-25`. Both aliases were right in light and wrong in dark — the
 * greens diverge (#43A774 vs Figma's #5FB386) and `--color-neutral-25` is not a
 * name tokens.css emits at all, so the ink was a hard #fcfaf7 in both modes
 * against a dark spec of #242322. That shape is also unrecoverable on its own:
 * the barrel concatenates components.css after tokens.css, so a `--color-*`
 * declared here outranks the generated token whenever plaza does ship one. The
 * extension layer avoids both halves — it declares under a prefix the generator
 * can never emit, so it can neither shadow nor be shadowed.
 */

.pi-msg {
  max-width: 85%;
  padding: var(--pi-space-3) var(--pi-space-4);
  border-radius: var(--pi-radius-lg);
  background: var(--color-message-background);
  color: var(--color-message-text);
  font-family: var(--pi-font-heading);
  font-size: 1.125rem;
  line-height: 1.45;
  letter-spacing: var(--pi-track-body);
}
/* data-author="self" — the group-chat variant PI-8941 introduces. */
.pi-msg[data-author="self"] {
  margin-left: auto;
  background: var(--miniapp-color-message-background-accent);
  color: var(--miniapp-color-message-text-contrast);
}
.pi-msg__author {
  display: block;
  margin-bottom: var(--pi-space-1);
  font-family: var(--pi-font-body);
  font-size: 0.75rem;
  font-weight: 500;
  line-height: var(--pi-lh-single);
  color: var(--color-text-secondary);
}

/* ══════════════════════════════════════════════════════════════ field */

.pi-field {
  width: 100%;
  min-height: var(--pi-control-lg);
  padding: var(--pi-space-3) var(--pi-space-4);
  border: 1px solid var(--color-input-border);
  border-radius: var(--pi-radius-md);
  background: var(--color-input-surface);
  color: var(--color-text-primary-base);
  font-family: var(--pi-font-body);
}
.pi-field::placeholder {
  color: var(--color-placeholder);
}
.pi-field:focus {
  outline: 2px solid var(--color-accent-alt-default);
  outline-offset: -1px;
}

/* — variant=app: Droplets' input and textarea ——————————————————————
 *
 * Same 12/16 padding and 10px radius as the base; three real differences: no
 * control-height floor (a one-line rename input and a four-row answer box are
 * both this class), a themed fill rather than `--color-input-surface`, and a
 * focus BORDER rather than an outline ring.
 *
 * `min-height: 0` and `outline: none` are the two declarations that exist only
 * to undo the base. The base's `outline-offset: -1px` survives on `:focus` and
 * is inert with no outline to offset.
 */
.pi-field[data-variant="app"] {
  width: 100%;
  min-height: 0;
  padding: 12px 16px;
  border: 1px solid var(--miniapp-color-border);
  border-radius: 10px;
  background: var(--miniapp-color-field);
  color: var(--miniapp-color-ink);
  font-family: inherit;
  font-size: 16px;
  font-weight: 500;
  letter-spacing: var(--pi-track-body);
  outline: none;
}

/* WebKit's placeholder is a fixed grey in both schemes, so on a dark field it is
   BRIGHTER than the real text and an empty box looks filled. `opacity: 1` is
   Firefox's 54% default, undone for the same reason. */
.pi-field[data-variant="app"]::placeholder {
  color: var(--color-placeholder);
  opacity: 1;
}

.pi-field[data-variant="app"]:focus {
  border-color: var(--color-accent-alt-default);
}

/* ══════════════════════════════════════════════════════════════ menu
 * Figma 6103:86776 (220×88, two items) and 6583:14181 (220×52, one item).
 */

.pi-menu {
  min-width: 220px;
  padding: var(--pi-space-1) 0;
  border: 1px solid var(--color-menu-stroke);
  /**
   * 4px (`--pi-radius-xs`), NOT the 10px `--pi-radius-md` this rule carried until now.
   *
   * 6583:14181 — one of the two nodes named above, and the one this rule was read off — binds
   * its `<Paper>` to `rounded-[var(--borderradius, 4px)]` and its `<MenuList>` to
   * `rounded-[4px]`. Every menu instance in the file agrees, including 6615:110427. So 10px was
   * never the design's number.
   *
   * IT IS ALSO VISIBLE, which is why it is worth a comment rather than a silent edit. The list
   * insets its items by `--pi-space-1` top and bottom, so a hovered item's fill stops 4px short
   * of the container — correct, and what the design does too (at 8px). But the fill is square
   * and the container was rounded at 10px, so at each corner the gap widened into a light nub
   * and the four of them read as a WHITE BORDER drawn around the hovered row. At 4px the corner
   * follows the inset instead of exceeding it and the fill reads as one band, which is what the
   * design shows. No test pinned the old value.
   */
  border-radius: var(--pi-radius-xs);
  background: var(--color-card-default);
  box-shadow: var(--shadow-menu-shadow);
  overflow: hidden;
}
.pi-menu__item {
  display: flex;
  align-items: center;
  gap: var(--pi-space-3);
  width: 100%;
  height: 44px;
  padding: 0 var(--pi-space-4);
  border: 0;
  background: transparent;
  color: var(--color-text-primary-base);
  font-family: var(--pi-font-body);
  font-size: 0.9375rem;
  font-weight: 500;
  text-align: left;
  cursor: pointer;
}
.pi-menu__item:hover {
  background: var(--color-fill-hover);
}
.pi-menu__item:active {
  background: var(--color-fill-tap);
}
.pi-menu__item[data-variant="destructive"] {
  color: var(--color-error-default);
}

/* ─────────────────────────────── the same menu, anchored, in three platform shapes
 * Lifted from Droplets (`apps/droplets/public/app.css`, whose private copy this
 * replaced). `data-platform` is written ON the menu — `web`, `android` or `ios` — and
 * the shape they share is keyed on the attribute's PRESENCE, so an unrecognised value
 * draws the web shape, which is what the source's unset arm did.
 *
 * ON THE MENU AND NOT ON AN ANCESTOR, which is load-bearing rather than a style choice:
 * apps already set `data-platform` on `<html>` (Stars Aligned does, from its Pi provider),
 * so `[data-platform="ios"] .pi-menu` would re-skin THEIR menu inside the Pi iOS app.
 *
 *   <div class="pi-menu" data-platform="ios" role="menu">
 *     <button class="pi-menu__item" data-variant="destructive" data-parted>
 *       <span class="pi-menu__ic">…svg…</span><span>Label</span>
 *
 * `data-parted` draws a divider UNDER the row, not between two of them, so the last row
 * never carries one. `__ic` and not `__icon`: Stars Aligned already owns that name.
 *
 * SELF-CONTAINED AGAINST THE DEFAULT ABOVE — it restates every property those rules
 * declare, so the two shapes cannot half-merge. `height` and `cursor` are here for that
 * reason alone: these rows size to their content and draw no desktop pointer.
 *
 * `top`/`right` are a first position only; a host anchoring this to a control overwrites
 * both, and the origin the entry scales from is the trailing top corner.
 *
 * THE iOS PLATE IS NOT HERE — see `css/press.css`. This arm carries geometry, and the
 * app that draws it on iOS paints the material over it.
 */
.pi-menu[data-platform] {
  position: absolute;
  top: 58px;
  right: 16px;
  min-width: 160px;
  max-width: 254px;
  padding: 8px;
  background: var(--color-menu-background);
  border: 1px solid var(--color-menu-stroke);
  border-radius: 10px;
  box-shadow: var(--shadow-menu-shadow);
  /* `auto` on y, not `hidden`: a host caps this platter to the space it has, and a cap
     with nothing to scroll just hides the last rows somewhere else. x stays clipped. */
  overflow: hidden auto;
  transform-origin: top right;
  animation: pi-menu-in .17s cubic-bezier(.32, .72, 0, 1) both;
}

/* EVERY NUMBER HERE WAS IN THE SHAPE ABOVE: this arm is not a new design, it is the one
   already being drawn finally saying who it is for. `max-width: none` is load-bearing —
   the 254 cap is the web shape's. */
.pi-menu[data-platform="android"] {
  min-width: 220px;
  max-width: none;
  padding: 8px 0;
  background: var(--miniapp-color-field);
  border: 1px solid var(--miniapp-color-border);
  border-radius: 14px;
  box-shadow: 0 12px 30px var(--miniapp-color-shadow);
}

/* The glyph is LEADING on iOS too. 40 is under Apple's 44pt floor and that is the
   designer's call on their own frame; the geometry is this component's, not a reading of
   UIKit's `UIMenu`. */
.pi-menu[data-platform="ios"] {
  min-width: 250px;
  max-width: none;
  padding: 10px 16px;
  border-radius: 34px;
  animation: pi-menu-in-ios .22s cubic-bezier(.32, .72, 0, 1) both;
}

/* THE ICON SLOT IS 20 WHERE THE DEFAULT'S IS 16, deliberately: this shape draws every
   glyph at 20, so what is taken is the 8 gap. `background: none` is also what neutralises
   the default's hover — this menu has no desktop hover state. */
.pi-menu[data-platform] .pi-menu__item {
  display: grid;
  grid-template-columns: 20px 1fr;
  gap: 8px;
  align-items: center;
  width: 100%;
  height: auto;
  cursor: auto;
  min-height: 40px;
  padding: 8px;
  background: none;
  border: none;
  border-radius: 8px;
  font: inherit;
  font-size: 16px;
  line-height: 1.4;
  letter-spacing: -0.16px;
  text-align: left;
  color: var(--color-text-accent);
}

/* Restated inside the variant because the row above outranks the default's own
   destructive rule; a row that only says `color` twice is how the red goes missing. */
.pi-menu[data-platform] .pi-menu__item[data-variant="destructive"] {
  color: var(--color-error-default);
}

.pi-menu[data-platform] .pi-menu__item:active {
  background: var(--miniapp-color-tap);
}

.pi-menu[data-platform] .pi-menu__ic {
  display: grid;
  place-items: center;
  width: 20px;
  margin-left: 0;
}

.pi-menu[data-platform] .pi-menu__item[data-parted] {
  position: relative;
}

.pi-menu[data-platform] .pi-menu__item[data-parted]::after {
  content: "";
  position: absolute;
  left: -8px;
  right: -8px;
  bottom: 0;
  height: 1px;
  background: var(--color-menu-stroke);
}

.pi-menu[data-platform="android"] .pi-menu__item {
  grid-template-columns: 52px 1fr;
  gap: 0;
  min-height: 36px;
  padding: 6px 16px 6px 0;
  border-radius: 0;
  line-height: var(--pi-lh-single);
  letter-spacing: normal;
}

.pi-menu[data-platform="android"] .pi-menu__ic {
  margin-left: 16px;
}

/* A real border and not the overhanging pseudo-element: this arm's rows are inset by
   the icon column, so a bleed to the platter edge would draw past the padding it has none of. */
.pi-menu[data-platform="android"] .pi-menu__item[data-parted] {
  border-bottom: 1px solid var(--miniapp-color-border);
}

.pi-menu[data-platform="android"] .pi-menu__item[data-parted]::after {
  content: none;
}

.pi-menu[data-platform="ios"] .pi-menu__item {
  grid-template-columns: 28px 1fr;
  gap: 8px;
  min-height: 40px;
  padding: 0 8px;
  border-radius: 12px;
  font-size: 17px;
  line-height: calc(20 / 17);
  letter-spacing: -0.43px;
}

.pi-menu[data-platform="ios"] .pi-menu__ic {
  width: 28px;
  margin-left: 0;
}

.pi-menu[data-platform="ios"] .pi-menu__item[data-parted]::after {
  content: "";
  position: absolute;
  left: -16px;
  right: -16px;
  bottom: 0;
  height: 1px;
  background: var(--miniapp-color-border);
}

/* Each exit carries its OWN name — CSS matches a running animation to the new
   `animation-name` list BY NAME, so an exit reusing its entry's name starts no exit at
   all. `[data-closing]` is on the holder, which is the host's node. */
@keyframes pi-menu-in {
  from { opacity: 0; transform: scale(.94) translateY(-6px); }
  to   { opacity: 1; transform: none; }
}

@keyframes pi-menu-in-exit {
  from { opacity: 0; transform: scale(.94) translateY(-6px); }
  to   { opacity: 1; transform: none; }
}

@keyframes pi-menu-in-ios {
  from { opacity: 0; transform: scale(.86); }
  to   { opacity: 1; transform: none; }
}

@keyframes pi-menu-ios-exit {
  from { opacity: 0; transform: scale(.86); }
  to   { opacity: 1; transform: none; }
}

[data-closing] .pi-menu[data-platform] {
  animation: pi-menu-in-exit .12s ease-in reverse both;
}

[data-closing] .pi-menu[data-platform="ios"] {
  animation: pi-menu-ios-exit .14s ease-in reverse both;
}

/* ══════════════════════════════════════════════════════════════ dialog
 * Figma 6589:31651 (compact, no body), 6053:56078 "Leave this group?" and
 * 6691:117564 "End {Activity_name}?" (default), 6121:111283 / 6121:111291
 * (the rename pair, title + input). One skeleton, three widths: the five
 * Droplets modals differ only in which slots they fill and how wide they are.
 *
 * The container values are pixel-probed off those nodes and every one of them
 * already has a token: 24px uniform padding is `--pi-space-6`, the 16px gap
 * between every section is `--pi-space-4`, the 1px stroke is `#modal/stroke`,
 * and the two stacked drop shadows are `--shadow-modal-shadow` verbatim.
 *
 * NO CLOSE BUTTON, and none is coming: all five modals carry a 32×32 icon
 * button in the title frame and it is HIDDEN in all five, at an x that in
 * 6589:31651 overflows its own title frame. Dismissal is the scrim and Cancel.
 *
 * NO DIALOG BUTTON CLASSES either — every button in all five is already
 * expressible in `.pi-btn`, and reproducing them here would be a second place
 * for the button rules to drift from bakery's cva. Cancel is
 * `data-variant="tertiary" data-size="medium"` (transparent, label
 * `--color-tertiary-default` #038247, which is the measured ghost label; the
 * size supplies the measured h=40 / radius 10 / min-width 96 / px-16). The
 * destructive confirm is `data-variant="negative" data-size="medium"`, whose
 * compound above is already outlined-not-filled — i.e. the file encodes the
 * design's rule that a destructive confirm is never a fill. The non-destructive
 * primary (Save, OK) is `data-variant="primary" data-size="medium"`.
 * `data-variant="ghost"` is the wrong Cancel: its label is `--color-text-
 * secondary`, not the green the design draws.
 */

:root {
  /* Derived by circular fit at four independent arc points on the rendered
     card, exact. It is not a rung of the scale — sm 8 / md 10 / lg 16 / xl 24
     have no 20 between them — so rather than bend a rung to fit one component,
     it is named for the component that needs it and declared here, beside the
     rules that consume it. */
  --pi-radius-dialog: 20px;
}

/* The backdrop and the centring box are one node: a second wrapper would only
   exist to be positioned identically. `--color-modal-backlay` is the token
   named for exactly this; the Figma nodes are isolated component specs with no
   overlay layer, so the value comes from the design system, not from taste. */
.pi-dialog-scrim {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: grid;
  place-items: center;
  padding: var(--pi-space-6);
  background: var(--color-modal-backlay);
}

/* `.pi-dialog-scrim[data-state="closing"]` is reserved and deliberately carries
   no rules — exit animation is the app's, but the attribute it hangs off is the
   DS's, so two apps cannot invent two different names for the same moment. */

.pi-dialog {
  display: flex;
  flex-direction: column;
  gap: var(--pi-space-4);
  padding: var(--pi-space-6);
  /* `--color-background` (#FAF3EA), not `--color-modal-background` (#FCFAF7):
     the probe returns (250,243,234) and the nodes bind `#base/background`, so
     the token whose NAME fits is not the token the design uses. */
  background: var(--color-background);
  border: 1px solid var(--color-modal-stroke);
  border-radius: var(--pi-radius-dialog);
  box-shadow: var(--shadow-modal-shadow);
  max-width: 100%;
  /* A long title or a long body on a short viewport would otherwise overflow
     the grid area and put the confirm button somewhere unreachable. */
  max-height: 100%;
  overflow-y: auto;
}

/* Widths are an enumerated set rather than three numbers discovered one at a
   time. Vertical rhythm is not set anywhere: padding + gap reproduce the
   measured 24/25/16/66/16/40/24 = 211 for free. */
.pi-dialog[data-width="compact"] {
  width: 248px; /* 6589:31651 — title + actions, no body. */
}
.pi-dialog[data-width="default"] {
  width: 300px; /* 6053:56078, 6691:117564 — title + body + actions. */
}
.pi-dialog[data-width="input"] {
  width: 345px; /* 6121:111283, 6121:111291 — title + field + actions. */
}

/* Body/M — 18/1.4, ls -2%. `--color-text-primary-base` is #131212, the measured
   `#base/text-primary`; `--color-text-primary` is #1A4631, the green. */
.pi-dialog__title {
  margin: 0;
  font-family: var(--pi-font-body);
  font-size: 1.125rem;
  font-weight: 500;
  line-height: var(--pi-lh-copy);
  letter-spacing: var(--pi-track-tight);
  color: var(--color-text-primary-base);
  text-align: left;
}

/* Body/S — 16/1.4, ls -1%. */
.pi-dialog__body {
  margin: 0;
  font-family: var(--pi-font-body);
  font-size: 1rem;
  font-weight: 500;
  line-height: var(--pi-lh-copy);
  letter-spacing: var(--pi-track-body);
  color: var(--color-text-secondary);
}

/* Wraps a `.pi-field` and its error line as ONE section, so the 8px between a
   field and its message stays distinct from the 16px between sections. */
.pi-dialog__field {
  display: flex;
  flex-direction: column;
  gap: var(--pi-space-2);
}

/* Label/S — 14/1.4, ls -2%. The leading warning triangle is the app's inline
   SVG: the gap reserves room for a glyph this file does not ship (see header). */
.pi-dialog__error {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0;
  font-family: var(--pi-font-body);
  font-size: 0.875rem;
  font-weight: 500;
  line-height: var(--pi-lh-copy);
  letter-spacing: var(--pi-track-tight);
  color: var(--color-error-default);
}

/* Cancel LEFT, confirm RIGHT, always — that order is constant across all five
   nodes. The ALIGNMENT is not: the confirmation modals (6053:56078 Leave,
   6691:117564 End activity) draw the row centred — 6691:117564 is a 300-wide
   card with 24 padding, so its content box is 252 and its 224-wide Actions frame
   sits at x=38, an equal 14px inset either side — while both rename modals
   (6121:111283, 6121:111291) draw it flush right.
   This was previously justified here as "the right-aligned nodes are the newer
   ones". That is not true: node ids rise monotonically within a file, and the
   right-aligned rename modals (6121:*) are OLDER than the centred End modal
   (6691:*). Nothing about age settles it, so nothing here pretends to — the DS
   ships both readings and each caller names the node it is building. The default
   stays flush-right so the rename family, the one with more instances, needs no
   attribute; the two confirmations pass data-align="center". */
.pi-dialog__actions {
  display: flex;
  gap: var(--pi-space-2);
  justify-content: flex-end;
}
.pi-dialog__actions[data-align="center"] {
  justify-content: center;
}

/* ══════════════════════════════════════════════════════════════ divider */

.pi-divider {
  height: 1px;
  border: 0;
  margin: 0;
  background: var(--color-divider-stroke);
}

/* ══════════════════════════════════════════════════════════════ empty state */

.pi-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--pi-space-3);
  padding: var(--pi-space-12) var(--pi-space-6);
  text-align: center;
}
.pi-empty__title {
  font-family: var(--pi-font-heading);
  font-size: 1.5rem;
  line-height: var(--pi-lh-heading);
  letter-spacing: var(--pi-track-heading);
  color: var(--color-text-primary);
  margin: 0;
}
.pi-empty__body {
  font-family: var(--pi-font-body);
  font-size: 1rem;
  line-height: var(--pi-lh-para);
  letter-spacing: var(--pi-track-body);
  color: var(--color-text-secondary);
  margin: 0;
  max-width: 30ch;
}

/* Between Us's own empty state: ONE centred paragraph, not a titled column, so it carries
   its own ink and size and takes no `__title`/`__body`. A VARIANT, per the card above.
   THE FOUR INITIAL VALUES ARE NOT NOISE. `display: block` alone would make the default's
   four flex declarations inert, but only while the box stays block — a consumer that
   re-declares `display: flex` on this node for its own layout gets the default's `center`
   and its `gap` back, invisibly. Stating them at the arm's own depth is what stops that. */
.pi-empty[data-variant="app"] {
  display: block;
  flex-direction: row;
  align-items: normal;
  justify-content: normal;
  gap: normal;
  padding: 28px 8px;
  text-align: center;
  color: var(--miniapp-color-muted);
  font-size: 14px;
  line-height: var(--pi-lh-copy);
}

/* ══════════════════════════════════════════════════════════════ visually-hidden */

.pi-sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}


/* ─── image-tile.css ─── */
/**
 * ImageTile — PI-8941 item 5.
 *
 * Consumer Library variant set 2860:28391. What this file builds is the
 * `Image=Contained` half of that set: the horizontal tile the ticket links
 * (2860:28385) and the vertical tile it asks for (10083:27804). The `Image=Fill
 * BG` half is deliberately absent; the note at the foot of this file says why.
 *
 * SURFACE AND INK MOVE AS A PAIR, and that is the point of the component. The
 * Contained tile's surface is `#card/*` and its ink is `#base/text-primary` and
 * `#base/text-secondary`. All five are semantic tokens from one collection, so
 * they flip in the same mode switch: there is no scheme in which the plate
 * darkens and the letters stay dark, because neither half is written down as a
 * value here. Nothing in this file is a literal colour — `test/image-tile.test.js`
 * asserts that, and asserts the resulting light-on-dark and dark-on-light pairs
 * clear AA in both modes.
 *
 * Every token name below was read off `get_variable_defs` on 2860:28385, which
 * is what makes the `#…` note beside each binding checkable rather than
 * remembered. The geometry — the numbers, not the names — comes from the frame
 * measurements on the same node; that tool returns variables only, so nothing
 * here claims it as the source for a pixel.
 */

/* ── the tile ─────────────────────────────────────────────────────────────
 *
 * States. PI-8941 item 5 asks for a LIGHTER default/hover/tap, and the lighter
 * set is `#card/*` — the previous rule painted the `#tile` group's
 * `default` / `default-hover` / `default-tap`. Derived from tokens.css, not
 * assumed:
 *
 *   light   #card/default #FCFAF7  over  #tile default      #FAF3EA   lighter
 *           #card/hover   #FAF3EA  over  #tile default-hover #F5EEE5  lighter
 *           #card/tap     #F7EFE3  over  #tile default-tap   #F0E9E1  lighter
 *   dark    #card/default #242322  over  #tile default      #242322   the same
 *           #card/hover   #2D2B29  over  #tile default-hover #23211E  lighter
 *           #card/tap     #33312E  over  #tile default-tap   #282622  lighter
 *
 * So: lighter across the board in light mode; in dark the resting surface is
 * unchanged and only the two press states lift. "Lighter" is true in light and
 * true-or-equal in dark, and that is exactly what the test pins.
 *
 * Elevation. Figma composes `Shadows/Shadow2` as
 *   0 12px 32px -6px #shadows/shadow-2-1, 0 2px 16px -6px #shadows/shadow-2-2
 * and plaza's `shadow-3` is that pair of layers — same offsets, blurs and
 * spreads — in both modes. plaza's own `shadow-2` is a single upward glow and
 * is a different effect entirely. Two plaza gaps follow and are reported on the
 * ticket rather than patched from here: the name is off by one, and the light
 * `shadow-2-1` stop has drifted to #e4dbc8 where Figma reads #e4d7c6.
 */

.pi-tile {
  display: flex;
  flex-direction: row;
  align-items: flex-end; /* Figma: horizontal tile is align-end */
  gap: var(--pi-space-4);
  min-height: 150px;
  /* Figma's min 150 is the media's 130 between two 10px insets. `min-height`
     rather than `height` so a long title grows the tile, and it is a floor on
     the border box, so the edge stroke puts the drawn tile at 152. */
  padding: 10px;
  border: 1px solid var(--color-edge-stroke); /* #tile/edge-stroke */
  background: var(--color-card-default);
  box-shadow: var(--shadow-shadow-3); /* = Figma Shadows/Shadow2, see above */
  cursor: pointer;
  transition: background-color var(--pi-dur) var(--pi-ease);

  /* Figma: 20px on three corners, 2px on the bottom-left. Written logically so
     the notch mirrors together with the layout under `direction: rtl` — the
     media's `order` below is already direction-relative, and a physical
     `border-radius` there would leave the notch stranded under the media.
     20px is not a step in scale.css's radius ramp (…16, 24…) and 2px is not
     either; both are this component's own, so both are literals with this
     comment rather than an invented rung. */
  border-start-start-radius: 20px;
  border-start-end-radius: 20px;
  border-end-end-radius: 20px;
  border-end-start-radius: 2px;
}

.pi-tile:hover {
  background: var(--color-card-hover);
}

.pi-tile:active {
  background: var(--color-card-tap);
}

/* Media sits at the inline END of the horizontal tile — Figma has it on the
   right. `order` rather than a DOM requirement, so the markup already in
   `docs/component-gallery.html` (media first) and the reading order a new
   consumer would write (title first) both render the Figma layout. */
.pi-tile:not([data-orientation="vertical"]) .pi-tile__media {
  order: 1;
}

/* Size=Medium-vertical, Figma 10083:27804: 158 wide, column, justify-end, media
   on top. Figma's 158 is 138 of media between two 10px insets; the reset makes
   every box `border-box`, so the 1px edge stroke eats into that and the media
   fills the content box rather than restating 138 and overflowing it by two. */
.pi-tile[data-orientation="vertical"] {
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-end;
  width: 158px;
}

.pi-tile[data-orientation="vertical"] .pi-tile__media {
  order: -1;
  width: 100%;
}

/* ── slots ──────────────────────────────────────────────────────────────── */

.pi-tile__media {
  flex: 0 0 auto;
  width: 138px;
  height: 130px;
  border-radius: var(--pi-radius-md); /* Figma: 10 */
  object-fit: cover;
  /* The well the artwork lands in before it decodes. Not a Figma binding —
     2860:28385 defines no fill for the slot — so this is the package's existing
     choice, carried forward unchanged, and it themes. */
  background: var(--color-fill-default);
}

.pi-tile__body {
  display: flex;
  flex-direction: column;
  gap: var(--pi-space-2); /* Figma: text block gap 8 */
  padding: 6px; /* Figma: text block padding 6 */
  min-width: 0;
  flex: 1 1 auto;
}

/* `Show description` and `Show chips` are booleans on the Figma component, so
   both slots are OPTIONAL. Omitting the element is the ordinary case and costs
   nothing — `gap` only applies between children that exist. These two rules
   cover the other case: a slot that is rendered but empty, which a template
   that always emits its wrapper produces, and which would otherwise open an 8px
   hole where the designer asked for no element at all. */
.pi-tile__chips:empty,
.pi-tile__desc:empty {
  display: none;
}

.pi-tile__chips {
  display: flex;
  gap: var(--pi-space-2);
  flex-wrap: wrap;
}

/* The chips themselves are `.pi-chip[data-variant="badge"]`, which already
   carries the Badge the tile spec describes — 1px `#base/divider-stroke`,
   padding 4/8, radius 48 and Label/XS at 12px, on a surface that resolves to
   #FAF3EA / #242322 — the same two values `#tile/default` carries, reached
   through `--color-default`. The tile lays the chips out and restyles nothing,
   so a change to the Badge reaches the tile for free. The one place the Badge
   is off the tile spec is its line-height: `--pi-lh-single` is 1.3 where Figma's
   Label/XS is 1.4. That belongs to the chip, and is raised on the ticket. */

/* Headings/All breakpoints/XS — Grenette Pro Regular 18, line-height 1.1,
   letter-spacing −1% (= −0.18px at 18px, which is `--pi-track-body`). 1.1 is
   tighter than any step in fonts.css's line-height set, so it is a literal. */
.pi-tile__title {
  margin: 0;
  font-family: var(--pi-font-heading);
  font-size: 1.125rem;
  font-weight: 400;
  line-height: 1.1;
  letter-spacing: var(--pi-track-body);
  color: var(--color-text-primary-base); /* #base/text-primary */
}

/* Body/All breakpoints/XS — DM Sans Medium 14, line-height 1.1,
   letter-spacing −1% (= −0.14px at 14px). */
.pi-tile__desc {
  margin: 0;
  font-family: var(--pi-font-body);
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.1;
  letter-spacing: var(--pi-track-body);
  color: var(--color-text-secondary); /* #base/text-secondary */
}

/* ── what is NOT here ─────────────────────────────────────────────────────
 *
 * `Image=Fill BG`. Those variants are the set's other pinning, and the
 * interesting one: artwork fills the tile and the ink sits ON the artwork, so
 * the pair is CONSTANT rather than themed — `#tile/background-fade` under
 * `#tile/contrast`, the same in light and dark, because the thing beneath the
 * letters is a photograph and does not know what scheme it is in. The mirror
 * confirms half of that: `--color-tile-contrast` is #FFFFFF in both modes.
 *
 * The other half is why the variant is not here. `get_variable_defs` on the
 * Contained node 2860:28385 returns neither name — the pairing is bound on
 * TileBackground, not on this node — and the mirror emits nothing called
 * background-fade at all. Building Fill BG would mean writing that fade down as
 * a hex, which is the defect this file is the answer to, so it is reported as a
 * token gap instead of approximated.
 *
 * `State=Loading` / `Image Loading` / `All Loading`. Not in PI-8941 item 5. The
 * colours are already in the mirror (`--color-skeleton-base` and
 * `--color-skeleton-shimmer`, both themed), so this is a motion question rather
 * than a token one, and it is left for whoever picks up the loading states.
 */


/* ─── avatar.css ─── */
/**
 * Avatar — PI-8941 item 6, "new user avatar component".
 *
 * Figma: consumer library `h4c5aqumP5IpXMlF1sqPva`, set 10086:28014
 * (Property 1 = Default 36px `10033:18312` | Small 28px `10086:28015`), read
 * 2026-08-21. The component in Figma is: a fixed round box, `#accented/tone-2`
 * fill, one centred DM Sans Medium letter in `#base/contrast`, and NOTHING
 * ELSE — no ring, no image slot, no status dot.
 *
 * WHAT "THE 5 COLOURS DERIVED FROM THE ACCENT TOKENS" ACTUALLY ARE. Not five
 * rungs of one ramp: five MODES of the `#accented` collection — the per-user
 * accent themes PI-6964 is adding. Two avatars in one ChatCard both bind the
 * single token `#accented/tone-2` and still come out different colours because
 * each instance pins a different mode (read straight off ChatCard 10086:27966,
 * where the two discs report `#accented/tone-2` = #6c92bd and #af77c4).
 *
 * SO THESE FIVE ARE LITERALS, AND THAT IS THE ONE PLACE IN THIS FILE A LITERAL
 * IS CORRECT. plaza's token layer has no theme axis at all — there is no
 * `accented` collection to generate from, so `tools/gen-tokens.mjs` cannot emit
 * these and `tokens.css` does not carry them. They are declared here, named for
 * the theme rather than for a hex, so the day PI-6964 lands the five
 * declarations below are deleted and every rule that consumes them is already
 * pointing at the right name. `test/people-components.test.js` allowlists
 * exactly these five and fails on any other hex in this file.
 *
 * WHY THE FIVE DO NOT CHANGE IN DARK, stated plainly rather than hidden.
 * Figma does not define a dark value for three of them. Only two dark partners
 * exist anywhere in the file (#108E52 for forest, #4677AE for ocean); the
 * Conversation page's dark frame 2812:48096 contains no Avatar instances at
 * all. Applying the two that exist would leave a row where two discs shift and
 * three do not, which is worse than none shifting. So the disc is PINNED across
 * both schemes and the INK on it is themed — `#base/contrast` is #FCFAF7 in
 * light and #242322 in dark. That pairing is what keeps both schemes legible,
 * and it is guarded, not assumed: the test computes all ten contrast ratios and
 * the worst is 3.11 (ocean, light). Pinning the fill WITHOUT the themed ink
 * would put #FCFAF7 on a mid tone in dark, which is the failure this note
 * exists to stop.
 */

:root {
  /* `#accented/tone-2` in each of the five accent themes. Source:
     `h4c5aqumP5IpXMlF1sqPva` 10083:27802, the "Examples" row of the Avatar doc
     frame — per-swatch nodes 10083:27792 / 27794 / 27796 / 27798 / 27800. */
  --pi-tone-forest: #3d9c6e;
  --pi-tone-ocean: #6c92bd;
  --pi-tone-nebula: #af77c4;
  --pi-tone-glow: #bb7b56;
  --pi-tone-bloom: #c5708b;
}

/* A `--pi-tone-*-1` set (the lighter `#accented/tone-1` rung) used to be
   declared alongside these. It is gone: nothing in this package or in droplets
   ever read one, and five unmoored hexes in the file whose rule is "no hexes"
   are the next thing to be copied somewhere by mistake. When a component needs
   tone-1, it comes back with that component and with a source node beside it. */

/**
 * SIZE NAMING, and why it is not Figma's.
 *
 * Figma calls 36px "Default" and 28px "Small". This file's bare `.pi-avatar` is
 * 28 and `data-size="lg"` is 36, because two consumers already bind that
 * spelling — `css/conversation.css` puts `<span class="pi-avatar"
 * data-size="lg">` in the OtherUser rail, and droplets' `avatarCluster()` emits
 * the bare class for a cluster member and `data-size="lg"` for a lone disc.
 * Inverting the default to match Figma's variant NAME would silently resize
 * every one of them. The ticket asks for a component, not a rename.
 *
 * Figma's 10px padding is deliberately not transcribed. The box is fixed at
 * 36/28 and holds one centred glyph, so the padding cannot move it — and under
 * this package's global `box-sizing: border-box` it would cut the 36px box's
 * content area to 16px, below the 25.2px line box, spilling the glyph into the
 * padding for no visual change at all.
 *
 * `border-radius` is `--pi-radius-round` rather than Figma's literal 36px: on a
 * 36 box those are the same circle, and on the 28 box CSS clamps 36px to
 * exactly 50% too, so the token is not an approximation of the literal — it is
 * the same rendering with a name.
 */
.pi-avatar {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  position: relative;
  width: 28px;
  height: 28px;
  /* NO BORDER. The 2px ring is the cluster's, below — the Figma component
     carries no stroke, and every ring in the design is drawn by the consumer
     that overlaps two discs. */
  border-radius: var(--pi-radius-round);
  background: var(--pi-tone-forest);
  color: var(--color-base-contrast);
  font-family: var(--pi-font-body);
  /* Label/XXS — DM Sans Medium 10/1.4. The one Pi style with no tracking. */
  font-size: 0.625rem;
  font-weight: 500;
  line-height: var(--pi-lh-copy);
  letter-spacing: 0;
  /* Not in Figma, where the letter is typed "A". Real callers pass the first
     character of a display name, whose case comes from user data, so the disc
     is normalised here rather than at five call sites. */
  text-transform: uppercase;
  user-select: none;
  overflow: hidden;
}

/* Body/All breakpoints/M — DM Sans Medium 18/1.4, ls -0.36px = -2%. */
.pi-avatar[data-size="lg"] {
  width: 36px;
  height: 36px;
  font-size: 1.125rem;
  letter-spacing: var(--pi-track-tight);
}

.pi-avatar[data-theme-tone="ocean"] {
  background: var(--pi-tone-ocean);
}
.pi-avatar[data-theme-tone="nebula"] {
  background: var(--pi-tone-nebula);
}
.pi-avatar[data-theme-tone="glow"] {
  background: var(--pi-tone-glow);
}
.pi-avatar[data-theme-tone="bloom"] {
  background: var(--pi-tone-bloom);
}

/* SEAT INK — for a disc the consumer tints from its own seat palette, not from
   the five pinned tones above. The base rule's `#base/contrast` is themed, and
   must be, because those five are not; a seat tint is neither, and the
   designer's 2026-09-02 ruling gives it one ink in both modes (iOS's
   `charcoal_50.colorset`, one universal entry). Lifted from droplets.
   ABOVE the `[data-state]` rules on purpose: those are (0,2,0) too and their
   `color: transparent` must keep winning — a pending disc has no letter. */
.pi-avatar[data-ink="seat"] {
  color: var(--miniapp-color-seat-ink);
}

/* Pending invite: nobody has accepted, so there is no identity to colour — a
   flat disabled fill with NO letter. Figma's `6583:14230`.
   NOT `--color-button-disabled-fill` DIRECTLY, though that is the token Figma
   names: the generated mirror resolves it to #AAA6A1 in dark where Figma says
   #8F877D. The extension token defers to the mirror in light, where the two
   agree byte for byte, and carries Figma's value in dark; the reasoning and the
   delete-me condition are in `css/miniapp-tokens.css`. */
.pi-avatar[data-state="pending"] {
  background: var(--miniapp-color-avatar-pending-fill);
  color: transparent;
}

/* Draft: never sent. Reads as a hollow ring — page-background fill with a
   divider-stroke border. Not a Figma state; droplets draws it for a row nobody
   has been invited to yet. The stroke is written in full here because the base
   avatar now carries no border for it to recolour. */
.pi-avatar[data-state="draft"] {
  background: var(--color-background);
  border: 2px solid var(--color-divider-stroke);
  color: transparent;
}

.pi-avatar > img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/**
 * Cluster — the "Group icon" slot of ChatCard (10086:27969) and of the
 * conversation header (6401:13624). A 40x40 box holding two 28px discs, the
 * first at (0,0) and the second at (12,12): 12 + 28 = 40, which is where the
 * box size comes from.
 *
 * The 2px `#base/background` ring lives HERE, on the cluster's children, not on
 * the avatar — that is what the Figma component boundary says, and it is also
 * the only reading that is correct on its own terms: the ring exists to punch a
 * page-coloured gap between two OVERLAPPING discs, so an avatar that overlaps
 * nothing has nothing to separate from.
 */
.pi-avatar-cluster {
  position: relative;
  flex: 0 0 auto;
  width: 40px;
  height: 40px;
  overflow: hidden;
}
.pi-avatar-cluster > .pi-avatar {
  position: absolute;
  border: 2px solid var(--color-background);
}
.pi-avatar-cluster > .pi-avatar:first-child {
  top: 0;
  left: 0;
}
.pi-avatar-cluster > .pi-avatar:nth-child(2) {
  bottom: 0;
  right: 0;
}
/* A draft disc keeps its own hollow-ring stroke inside a cluster. Both rules
   are (0,2,0) so source order alone would decide it, and source order is not a
   reason: the cluster ring exists to separate two overlapping discs, while a
   draft disc's stroke IS its state, and losing it would render a draft
   identically to an accepted one. */
.pi-avatar-cluster > .pi-avatar[data-state="draft"] {
  border-color: var(--color-divider-stroke);
}


/* ─── reactions.css ─── */
/**
 * Reactions — PI-8941 items 7 and 9.
 *
 *   item 7  Reacts        the chip parked on a reacted-to message
 *                         Figma `h4c5aqumP5IpXMlF1sqPva` 10041:18967, usage
 *                         `DA1dmWE9oso2rCNlDLiZNd` 6401:13615
 *   item 9  ReactionButton  + ReactionEmojiBar, separated
 *                         usage `DA1dmWE9oso2rCNlDLiZNd` 6401:13513, whose bar
 *                         instance is 7581:19814 (`ReactionBar/Reaction-full-bar`)
 *
 * WHERE THE GEOMETRY WAS READ, and why not from the nodes the ticket names.
 * PI-8941 gives 9054:27045 for ReactionButton and 10256:45199 for
 * ReactionEmojiBar. Both of those resolve to `Headings` instances — the black
 * section banners that label a page of the library ("ReactionButtons" at
 * x=4592, the other at x=5279, both y=2350), not the components under them. The
 * numbers below therefore come from the USAGE node the ticket also gives,
 * 6401:13513, which instantiates the real bar and every button in it. Read
 * 2026-08-21. If someone later resolves the component nodes themselves and they
 * disagree, the component wins and this note is the place to start.
 *
 *   <span class="pi-reacts">
 *     <span class="pi-reacts__emoji">&#10084;&#65039;</span>
 *     <span class="pi-reacts__count">2</span>          <!-- optional -->
 *   </span>
 *
 *   <div class="pi-reaction-bar" role="group" aria-label="React">
 *     <button class="pi-reaction-btn" aria-pressed="true">&#10084;&#65039;</button>
 *     <button class="pi-reaction-btn" aria-pressed="false">&#128518;</button>
 *     ...
 *   </div>
 *
 * The chip does NOT position itself. Figma parks it over the bubble's
 * bottom-trailing corner, and the anchor for that is `.pi-convo-msg__reacts` in
 * `css/conversation.css` — items 2-4's business. Measured off 6401:13615 for
 * whoever reconciles the two: on both reacted messages there the chip's right
 * edge sits 8px INSIDE the bubble's right edge (295 against 303) and the chip
 * is centred on the bubble's bottom edge (72..97 against a bubble ending at 85;
 * 150..175 against one ending at 163).
 *
 * Every colour here is a `--color-*` token, so both schemes fall out of one set
 * of rules. There is no dark block in this file and there must not be one.
 */

/* ══════════════════════════════════════════════════════════════ Reacts chip
 * Figma 10041:18967 — Property 1 = One (24x25) | More than 1 (34x25) |
 * Multiple emoji (46x25). The three differ only in their CONTENT: one emoji,
 * one emoji plus a count, two emoji plus a count. Nothing about the box
 * changes, so there is no variant attribute here — the markup carries the
 * difference, which is also what stops a caller picking a variant that
 * disagrees with what it put inside.
 *
 * The 2px `#base/background` ring is on the chip itself in Figma (unlike the
 * avatar's) because the chip is always drawn OVER a message bubble: the ring is
 * what separates it from the bubble's fill.
 */

.pi-reacts {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* 4px between the emoji and its count. Inert in the one-emoji variant, which
     has no second child. */
  gap: var(--pi-space-1);
  /* Figma 4px block / 6px inline. 6 is off the 4px step and has no rung; the
     package writes such a value raw (see `.pi-btn`'s gap) rather than bend the
     scale for one component. */
  padding: var(--pi-space-1) 6px;
  border: 2px solid var(--color-background);
  /* Figma says 40px. On a 25px-tall box CSS clamps any radius at or above half
     the height to the same stadium, so 40 and the scale's 48px pill step render
     identically — this is the token, not an approximation of the literal. */
  border-radius: var(--pi-radius-chip);
  background: var(--color-message-background);
  font-family: var(--pi-font-body);
  white-space: nowrap;
  overflow: hidden;
}

/* 12/1.45, ls -0.12px = -1%. Figma leaves the fill unbound (plain #000000).
   A colour emoji paints its own glyph and ignores this entirely; it shows only
   where the platform falls back to a monochrome outline, and there a hard black
   would be invisible on the dark scheme's #2D2B29 bubble. So the ink token is
   the fallback's, and there is nothing for it to override in the normal case. */
.pi-reacts__emoji {
  font-size: 0.75rem;
  line-height: 1.45;
  letter-spacing: var(--pi-track-body);
  color: var(--color-text-primary-base);
}

/* Label/XXS — DM Sans Medium 10/1.4, no tracking. */
.pi-reacts__count {
  font-size: 0.625rem;
  font-weight: 500;
  line-height: var(--pi-lh-copy);
  letter-spacing: 0;
  color: var(--color-text-secondary);
}

/* ══════════════════════════════════════════════════════════════ ReactionButton
 * 40x40, one 24px emoji, centred. Unselected it paints the bar's own
 * `#menu/background`, so inside the bar it is invisible until it is chosen.
 *
 * SELECTION. The ticket's own usage draws it: the ❤️ in 7581:19814 carries
 * `#menu/tap` and a 100px radius while its five siblings carry the bar fill and
 * no radius. That is the whole of the selected treatment and it is the only
 * state the file defines — the variant set exposes `Property 1 = Default` and
 * nothing else, so there is no hover here. `:active` reuses the selected
 * treatment rather than inventing a third: it is the design's own "this one is
 * chosen" ink, shown a moment early, which is the same call `.pi-chatcard`
 * makes for the same reason.
 */

.pi-reaction-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 0;
  background: var(--color-menu-background);
  color: var(--color-text-primary-base);
  font-family: var(--pi-font-body);
  /* 24/1.45, ls -0.24px = -1%. */
  font-size: 1.5rem;
  line-height: 1.45;
  letter-spacing: var(--pi-track-body);
  cursor: pointer;
  overflow: hidden;
  user-select: none;
  transition: background-color var(--pi-dur) var(--pi-ease);
}

/* Figma's 100px on a 40x40 box clamps to exactly 50%, so the token is the same
   circle rather than a rounding of it. */
.pi-reaction-btn[aria-pressed="true"],
.pi-reaction-btn:active {
  border-radius: var(--pi-radius-round);
  background: var(--color-menu-bg-tap);
}

.pi-reaction-btn:disabled,
.pi-reaction-btn[aria-disabled="true"] {
  pointer-events: none;
  opacity: 0.5;
}

/* ══════════════════════════════════════════════════════════════ ReactionEmojiBar
 * The pill the buttons sit in — separated from the button per item 9, so a
 * surface that wants one reaction control does not have to instantiate a bar.
 *
 * `--shadow-shadow-3` is plaza's name for Figma's `Shadows/Shadow2`: the
 * geometry is identical (0 2px 16px -6px, and 0 12px 32px -6px), and the second
 * colour drifts by three channels — plaza #E4DBC8 against Figma's
 * `#shadows/shadow-2-1` #E4D7C6. Recorded, not corrected: renaming or
 * revaluing a generated shadow is plaza's change to make, and the token is the
 * only spelling that also carries the dark value.
 *
 * SIZE. Figma reports 248x48, from six 40px buttons plus 4px padding, with the
 * 1px stroke drawn INSIDE the padding. CSS puts a border outside the padding
 * box, so this renders 250x50 — 1px larger on each edge. Not compensated by
 * shaving the padding to 3px, because that would trade a visible 4px gutter for
 * an invisible 2px of overall width.
 */

.pi-reaction-bar {
  display: inline-flex;
  align-items: center;
  /* No gap: the buttons abut, and their 40px boxes are the hit targets. */
  padding: var(--pi-space-1);
  border: 1px solid var(--color-edge-stroke);
  border-radius: var(--pi-radius-chip);
  background: var(--color-menu-background);
  box-shadow: var(--shadow-shadow-3);
  overflow: hidden;
}


/* ─── conversation.css ─── */
/**
 * Conversation — the three group-chat message components of PI-8941.
 *
 *   item 2  Conversation/Pi        group-chat variant + hover   (Figma 10031:6134)
 *   item 3  Conversation/OtherUser new component      + hover   (Figma 10033:18334)
 *   item 4  Conversation/User      group-chat variant + hover   (Figma 475:1384)
 *
 * One skeleton, three values of `data-from` — `pi`, `other`, `self` — because
 * the three Figma sets differ only in which rail leads the row, which token
 * pair paints the bubble, and which edge the row hugs. Variants are DATA
 * ATTRIBUTES for the reason components.css gives at the top of the file.
 *
 *   <div class="pi-convo">
 *     <div class="pi-convo-msg" data-from="pi">
 *       <div class="pi-convo-msg__rail"><img class="pi-convo-msg__icon" alt=""></div>
 *       <div class="pi-convo-msg__col">
 *         <div class="pi-convo-msg__line">
 *           <div class="pi-convo-msg__bubble">…</div>
 *         </div>
 *         <p class="pi-convo-msg__note">Annotation</p>   <!-- optional -->
 *       </div>
 *     </div>
 *
 *     <div class="pi-convo-msg" data-from="other">
 *       <div class="pi-convo-msg__rail"><span class="pi-avatar" data-size="lg">A</span></div>
 *       <div class="pi-convo-msg__col">
 *         <span class="pi-convo-msg__name">Alex</span>
 *         <div class="pi-convo-msg__line">
 *           <div class="pi-convo-msg__bubble">…</div>
 *           <div class="pi-convo-msg__actions">
 *             <button class="pi-icon-btn" data-size="small" data-variant="bare">…</button>
 *             <button class="pi-icon-btn" data-size="small" data-variant="bare">…</button>
 *           </div>
 *         </div>
 *       </div>
 *     </div>
 *
 *     <div class="pi-convo-msg" data-from="self">
 *       <div class="pi-convo-msg__line">
 *         <div class="pi-convo-msg__bubble">…</div>
 *         <div class="pi-convo-msg__actions">…</div>
 *       </div>
 *     </div>
 *   </div>
 *
 * DOM order is always bubble-then-actions; `self` reverses the pair visually so
 * the actions land on the inboard side of a right-hugging bubble, and a screen
 * reader still hears the message before the things you can do to it.
 *
 * NOT in here: the reaction chip itself. `__reacts` is only the anchor that
 * parks a child in the bubble's bottom-trailing corner — the chip is PI-8941
 * item 7 and lives with the emoji-react components.
 *
 * Every colour is a `--color-*` token from tokens.css, so both schemes come out
 * of one set of rules. There is no dark-mode block in this file and there must
 * not be one: a rule that only fires in dark means a token was chosen wrong.
 */

/* ── the conversation container ─────────────────────────────────────────────
 * Optional. The 75% cap below measures against the ROW, not this element, so a
 * consumer that already has its own scroller can skip it — but the two must be
 * the same width for "75% of the conversation container" to mean what Figma
 * means, which is the case whenever rows are the stack's direct children.
 *
 * The 24px stack gap is NOT from PI-8941 — the ticket's three items describe a
 * row, not a thread. It is the `Combined Conversation` gap already transcribed
 * in apps/droplets/public/index.html, kept here so the two agree.
 */
.pi-convo {
  display: flex;
  flex-direction: column;
  gap: var(--pi-space-6);
  width: 100%;
}

/* ── the row ────────────────────────────────────────────────────────────────
 * Figma: row, flex, gap 8, align-start, w345 (`self` is align-center,
 * justify-end). The 345 is the S/M artboard, not a cap — the component is
 * responsive by the designer's own annotation.
 *
 * `container-type: inline-size` is the whole trick behind that annotation.
 * The bubble sits two levels down, past a 36px rail and an 8px gap, so a plain
 * `max-width: 75%` there would resolve against the COLUMN (301px of a 345px
 * row at S/M) and cap the bubble at 225.75px where Figma says 258.75px. Making the
 * row a query container lets the bubble ask the row — i.e. the conversation
 * container — for its width directly, which is what `75cqi` reads.
 */
.pi-convo-msg {
  display: flex;
  align-items: flex-start;
  gap: var(--pi-space-2);
  width: 100%;
  container-type: inline-size;
}
.pi-convo-msg[data-from="self"] {
  align-items: center;
  justify-content: flex-end;
}

/* ── the rail: Pi's app icon, or the other user's avatar ────────────────── */

.pi-convo-msg__rail {
  display: flex;
  flex: 0 0 auto;
  width: 36px;
}
/* An avatar sits beside the BUBBLE, and `other` has a name line above the
   bubble, so its rail drops by that line's height — Figma's 20px padding-top on
   the 36-wide container. Pi has no name line and no offset. */
.pi-convo-msg[data-from="other"] .pi-convo-msg__rail {
  padding-top: var(--pi-space-5);
}
.pi-convo-msg[data-from="self"] .pi-convo-msg__rail {
  display: none;
}

/* Figma: App Icon 36×36, radius 10, fill #base/background — the page's own
   colour behind a transparent mark, which is why it is `--color-background`
   and not a surface token. */
.pi-convo-msg__icon {
  width: 36px;
  height: 36px;
  border-radius: var(--pi-radius-md);
  background: var(--color-background);
}

/* ── the column ─────────────────────────────────────────────────────────────
 * Figma: column, flex 1, gap 8 (Pi) / gap 4 (OtherUser). `min-width: 0` is
 * load-bearing on a flex child — without it the column's min-content width is
 * the longest unbreakable word, so one 60-character token widens the row.
 */
.pi-convo-msg__col {
  display: flex;
  flex-direction: column;
  gap: var(--pi-space-2);
  flex: 1 1 auto;
  min-width: 0;
}
.pi-convo-msg[data-from="other"] .pi-convo-msg__col {
  gap: var(--pi-space-1);
}

/* Label/XS — DM Sans Medium 12/1.4, tracking -0.12px (= -0.01em at 12px, which
   is what --pi-track-body is), #base/text-secondary. A name is somebody else's
   to choose, so it truncates rather than widening the row. */
.pi-convo-msg__name {
  font-family: var(--pi-font-body);
  font-size: 0.75rem;
  font-weight: 500;
  line-height: var(--pi-lh-copy);
  letter-spacing: var(--pi-track-body);
  color: var(--color-text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ── bubble + hover actions ─────────────────────────────────────────────── */

/**
 * Bubble and its hover actions, side by side.
 *
 * No gap, and that is measured rather than tidy. Figma's hover frame
 * over-constrains itself: a 345 row less the 44px rail leaves a 301 column, and
 * the frame puts a 240 bubble and a 64 Message_actions inside it — 304. So the
 * two abut with about 3px of overlap and there is no room for a gap at all.
 * Flex shrink resolves the 3px by narrowing the bubble to 237 at S/M rather
 * than pushing the row past the container, which is the safe direction to be
 * wrong; any positive gap here would come out of the bubble on top of that.
 */
.pi-convo-msg__line {
  display: flex;
  align-items: flex-start;
  gap: 0;
  min-width: 0;
  max-width: 100%;
}
/* `self` has no column: the line is the row's only child, so the row's
   justify-end is what hugs it right. Reversing the pair puts the actions
   inboard of the bubble without moving them in the DOM. */
.pi-convo-msg[data-from="self"] .pi-convo-msg__line {
  flex-direction: row-reverse;
}

/**
 * The bubble.
 *
 * Figma: fill #message/background, max-width 258.75 (75% of the 345 container),
 * padding 6/12, radius 10, Body-Alt/Mobile & Tablet/M = Grenette Pro Regular
 * 18/1.45 tracking -0.18px (= -0.01em at 18px), ink #message/text.
 *
 * The two max-widths are a fallback pair, not a mistake. `cqi` needs container
 * queries (WebKit 16, Chromium 105); where they are missing the first
 * declaration stands and 75% resolves against the column instead — narrower
 * than the spec on the `pi`/`other` rows by the rail's 44px, exactly right on
 * `self`, and never wider than the cap. That is the safe direction to be wrong.
 *
 * `text-wrap: pretty` is the designer's own annotation, verbatim: "Make max
 * width of message block 75% of horizontal width of conversation container so
 * it is responsive. Use text-wrap: pretty."
 *
 * 6px has no step in scale.css (the ladder is 4/8/12/16/20/24) — reported as a
 * gap rather than rounded to 4 or 8, because the bubble's height is the thing
 * every other measurement in the frame is taken against.
 */
.pi-convo-msg__bubble {
  position: relative;
  min-width: 0;
  max-width: 75%;
  max-width: 75cqi;
  padding: 6px var(--pi-space-3);
  border-radius: var(--pi-radius-md);
  background: var(--color-message-background);
  color: var(--color-message-text);
  font-family: var(--pi-font-heading);
  font-size: 1.125rem;
  font-weight: 400;
  line-height: 1.45;
  letter-spacing: var(--pi-track-body);
  text-wrap: pretty;
  overflow-wrap: anywhere;
}

/* item 4 — the current user's own message. The only pair in this file that
   changes BOTH surface and ink together, which is why they are set together
   here, and the only two colours it takes from `css/miniapp-tokens.css` rather
   than from the generated mirror: plaza's design system defines neither, and a
   mini app does not change Pi's design system to get one. The `--miniapp-`
   prefix is what keeps this from silently shadowing the day it does. */
.pi-convo-msg[data-from="self"] .pi-convo-msg__bubble {
  background: var(--miniapp-color-message-background-accent);
  color: var(--miniapp-color-message-text-contrast);
}

/* The optional Annotation under Pi's bubble — DM Sans Medium 12/1.4,
   #base/text-secondary. */
.pi-convo-msg__note {
  margin: 0;
  font-family: var(--pi-font-body);
  font-size: 0.75rem;
  font-weight: 500;
  line-height: var(--pi-lh-copy);
  letter-spacing: var(--pi-track-body);
  color: var(--color-text-secondary);
}

/**
 * Hover — Figma's `State = Group chat-hover` on all three sets.
 *
 * Two triggers on purpose. `data-state="hover"` is the variant as Figma models
 * it, settable from markup so the state can be rendered, screenshotted and
 * tested without a pointer; `:hover` is the live behaviour, and it is fenced
 * behind `(hover: hover)` because a sticky :hover on a touch surface would leave
 * the action buttons showing on the last message anybody tapped.
 *
 * The actions are `display: none` until then rather than hidden-but-reserved:
 * OtherUser's frame grows 111 -> 137 between the two states, and 26px is one
 * line of 18/1.45 — the height comes from the bubble reflowing as it narrows,
 * so the group is genuinely absent in the default state.
 */
.pi-convo-msg__actions {
  display: none;
  flex: 0 0 auto;
  align-items: center;
  gap: 0; /* Figma: Message_actions is 64×32 = two 32×32 buttons, flush. */
  height: var(--pi-control-sm);
}
.pi-convo-msg[data-state="hover"] .pi-convo-msg__actions {
  display: flex;
}
@media (hover: hover) and (pointer: fine) {
  .pi-convo-msg:hover .pi-convo-msg__actions {
    display: flex;
  }
}

/* Hover narrows the bubble to make room for the group beside it: Figma's
   OtherUser goes 259 -> 240 inside the same 345 container. Expressed as that
   ratio rather than as a third hard number, so it stays responsive — and both
   numbers come from the frame, neither is derived from the other. */
.pi-convo-msg[data-state="hover"] .pi-convo-msg__bubble {
  max-width: 69.6%;
  max-width: calc(100cqi * 240 / 345);
}
@media (hover: hover) and (pointer: fine) {
  .pi-convo-msg:hover .pi-convo-msg__bubble {
    max-width: 69.6%;
    max-width: calc(100cqi * 240 / 345);
  }
}

/* ── the reaction anchor ────────────────────────────────────────────────────
 * Figma parks the Reacts chip on the bubble's bottom-trailing corner, over the
 * edge, so it reads as applied TO the message rather than as part of it. This
 * is the anchor only — whatever child goes in it is PI-8941 item 7's to draw,
 * and this file deliberately sets no fill, border or size on it.
 *
 * Trailing, not right: `self`'s bubble hugs the other edge, so the corner is
 * addressed with `inset-inline-end` and follows the writing direction.
 */
.pi-convo-msg__reacts {
  position: absolute;
  inset-block-end: 0;
  inset-inline-end: 0;
  transform: translate(25%, 50%);
}
.pi-convo-msg[data-from="self"] .pi-convo-msg__reacts {
  inset-inline-end: auto;
  inset-inline-start: 0;
  transform: translate(-25%, 50%);
}


/* ─── chat-card.css ─── */
/**
 * ChatCard — PI-8941 item 8.
 *
 * Figma: consumer library `h4c5aqumP5IpXMlF1sqPva` 10086:27966 (the component)
 * and `DA1dmWE9oso2rCNlDLiZNd` 6589:31513 (the Recents list that uses it), both
 * read 2026-08-21. Four booleans on the component decide the row:
 * `Show badge` (t) · `Show chevron` (t) · `Show menu button` (f) · `Show mute` (f).
 *
 *   <div role="button" class="pi-chatcard">
 *     <span class="pi-chatcard__leading">
 *       <span class="pi-avatar-cluster">…two .pi-avatar…</span>
 *       <span class="pi-chatcard__body">
 *         <span class="pi-chatcard__text">
 *           <span class="pi-chatcard__title">Group name</span>
 *           <span class="pi-chatcard__meta">
 *             <span class="pi-chatcard__subtitle">{Activity name}</span>
 *             <svg class="pi-chatcard__mute">…</svg>          <!-- optional -->
 *           </span>
 *         </span>
 *       </span>
 *     </span>
 *     <span class="pi-chatcard__trailing">
 *       <span class="pi-chatcard__badge">Your turn</span>      <!-- optional -->
 *       <svg class="pi-chatcard__chevron">…</svg>              <!-- optional -->
 *       <button class="pi-chatcard__kebab">…</button>          <!-- optional -->
 *     </span>
 *   </div>
 *
 * THE ROW IS 75px, and it is 75 for a reason that is checkable rather than set:
 * 16px padding + a content band + 16px padding, where the band is the title's
 * 18x1.1 = 19.8 plus an 8px gap plus the subtitle's 14x1.1 = 15.4, i.e. 43.2.
 * Nothing here sets a height; if a type value changes the row changes with it.
 * Rows stack with NO gap — the bottom divider does the separating.
 *
 * TWO GAPS, NOT ONE. Figma nests a `Leading` frame (10086:27968, gap 8, flex-1)
 * inside the row (gap 16). So the avatar-to-text gap is 8 and the
 * text-to-trailing gap is 16, and a flat row with one gap cannot produce both.
 * `__leading` exists for exactly that and for nothing else.
 *
 * ONE FIGMA FRAME IS DELIBERATELY COLLAPSED: 10086:27975, a gap-8 row between
 * `__body` and `__text` holding the Text column plus two badge slots that are
 * hidden in the component and are drawn in the trailing group by every usage.
 * A wrapper whose only children are one visible column and two permanently
 * hidden siblings has nothing to lay out.
 *
 * Every colour is a `--color-*` token, so both schemes come out of one set of
 * rules. There is no dark block in this file and there must not be one.
 */

.pi-chatcard {
  display: flex;
  align-items: center;
  gap: var(--pi-space-4);
  width: 100%;
  padding: var(--pi-space-4) 0;
  border: 0;
  border-bottom: 1px solid var(--color-divider-stroke);
  background: transparent;
  text-align: left;
  overflow: hidden;
  cursor: pointer;
  font-family: var(--pi-font-body);
  transition: background-color var(--pi-dur) var(--pi-ease);
}
/* No hover or pressed state exists in Figma — every variant in the file is
   State=Default. This is the smallest honest affordance rather than an invented
   one, and it reuses the card token rather than picking a shade. */
.pi-chatcard:active {
  background: var(--color-card-tap);
}

.pi-chatcard__leading {
  display: flex;
  align-items: center;
  gap: var(--pi-space-2);
  flex: 1 0 0;
  min-width: 0;
}

.pi-chatcard__body {
  display: flex;
  flex-direction: column;
  gap: var(--pi-space-1);
  min-width: 0;
  flex: 1 0 0;
}
.pi-chatcard__text {
  display: flex;
  flex-direction: column;
  gap: var(--pi-space-2);
  min-width: 0;
}
/* The subtitle and, when the group is muted, a 15px icon beside it. Top-aligned
   in Figma, which matters only when the subtitle wraps — and it does not, it
   ellipsises. */
.pi-chatcard__meta {
  display: flex;
  align-items: flex-start;
  gap: var(--pi-space-2);
  min-width: 0;
}
.pi-chatcard__mute {
  flex: 0 0 auto;
  width: 15px;
  height: 15px;
  color: var(--color-text-secondary);
}

/* Headings/All breakpoints/XS — Grenette Pro Regular 18/1.1, ls -0.18px = -1%.
   `liga 0` because Grenette's default ligatures fuse letter pairs in a name. */
.pi-chatcard__title {
  font-family: var(--pi-font-heading);
  font-size: 1.125rem;
  font-weight: 400;
  line-height: 1.1;
  letter-spacing: var(--pi-track-body);
  font-feature-settings: "liga" 0;
  color: var(--color-text-primary-base);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin: 0;
}

/* Body/All breakpoints/XS — DM Sans Medium 14/1.1, ls -0.14px = -1%. */
.pi-chatcard__subtitle {
  font-family: var(--pi-font-body);
  font-size: 0.875rem;
  font-weight: 500;
  line-height: 1.1;
  letter-spacing: var(--pi-track-body);
  color: var(--color-text-secondary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin: 0;
}

.pi-chatcard__trailing {
  display: flex;
  align-items: center;
  gap: var(--pi-space-2);
  flex-shrink: 0;
}

/* — badge —————————————————————————————————————————————————————
 * Label/XS — DM Sans Medium 12/1.4, ls -0.12px = -1%; 4px block / 8px inline
 * padding, which lands the pill at 25px. The library's own default is this
 * light bordered form; the two solid fills below are the Droplets rows'
 * overrides of it, kept here so the row does not restate them per screen.
 */
.pi-chatcard__badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--pi-space-1) var(--pi-space-2);
  border: 1px solid var(--color-divider-stroke);
  border-radius: var(--pi-radius-chip);
  background: var(--color-tile-default);
  color: var(--color-text-secondary);
  font-size: 0.75rem;
  font-weight: 500;
  line-height: var(--pi-lh-copy);
  letter-spacing: var(--pi-track-body);
  white-space: nowrap;
  overflow: hidden;
}
/* "Your turn" — it is this user's move. Stays until they respond. */
.pi-chatcard__badge[data-kind="turn"] {
  border-color: transparent;
  background: var(--color-base-contrast-inverted);
  color: var(--color-contrast);
}
/* "New" — answers from other people, not yet seen. Clears on viewing. */
.pi-chatcard__badge[data-kind="new"] {
  border-color: transparent;
  background: var(--color-accent-alt-default);
  color: var(--color-contrast);
}

/* — chevron / kebab ————————————————————————————————————————————
 * The kebab is Figma's SquareIconButton at 32x32 / padding 6 / radius 10, with
 * no fill: an affordance, not a filled button.
 */

.pi-chatcard__chevron {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  color: var(--color-text-secondary);
}
.pi-chatcard__kebab {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 32px;
  height: 32px;
  padding: 6px;
  border: 0;
  border-radius: var(--pi-radius-md);
  background: transparent;
  color: var(--color-text-secondary);
  cursor: pointer;
}
.pi-chatcard__kebab:active {
  background: var(--color-fill-hover);
}


/* ─── radio.css ─── */
/**
 * Radio — the selection circle, which is Pi's `RadioButtonView` in CSS.
 *
 *   component  apple-pudding
 *              `DesignSystem/Views/Radio/RadioButtonView.swift:25-52`
 *   usage      Figma `DA1dmWE9oso2rCNlDLiZNd` 7670:78145 (delete mode), whose
 *              Radio instances are `7670:79916` unticked and `7670:79936`
 *              ticked. Read 2026-08-29.
 *   glyph      apple-pudding `DesignSystem/Resources/Assets/Assets.xcassets/
 *              Core/checkFilled_16.imageset/checkFilled_16.svg`
 *
 *   <button type="button" role="checkbox" aria-checked="false"
 *           aria-label="Select this message">
 *     <span class="pi-radio"></span>
 *   </button>
 *
 * WHY IT IS HERE AND NOT IN AN APP'S STYLESHEET. Droplets drew this inline and
 * PI-9852 asked for it to be part of the design system, which is this package.
 * `reactions.css` is the shape that answer takes here: one sheet per component,
 * owning its selectors outright, bound to `--color-*` tokens rather than to any
 * app's aliases, and named for the Figma node it was read off. This is that,
 * for the second component.
 *
 * THE CHECKED SELECTOR IS THE ANCESTOR'S, NOT THE CIRCLE'S, and that is the one
 * decision that lets one sheet serve two different controls. The circle is a
 * bare `<span>` with no state of its own; the thing that is checked is the
 * `<button>` around it, and that button is `role="checkbox"` on a surface where
 * several can be picked at once and `role="radio"` where exactly one can. A
 * sheet that keyed off `.pi-radio[aria-checked]` would force every consumer to
 * put the ARIA on a span, which announces nothing. `[aria-checked="true"]
 * .pi-radio` reads the state off whichever ancestor legitimately carries it and
 * costs the consumer no markup at all.
 *
 * 20x20, AND THE 20 IS THE BORDER BOX. `reset.css` in this same barrel sets
 * `box-sizing: border-box` on everything, so a 2px ring is drawn INSIDE the 20
 * and the circle measures 20 across. That is the frame's own number: `7670:79916`
 * rasterises at 1:1 to a 20px circle with a 2px ring in `#655E55`, which is
 * `--color-text-secondary` exactly. It is NOT the number the device draws —
 * SwiftUI's `Circle().stroke(lineWidth: 2).frame(width: 20)` centres the stroke
 * on the path and comes out 22 across, which is what a screenshot of the shipped
 * app measures. The frame wins on a web surface: 22 would put the circle in a
 * gutter the frame sized for 20.
 *
 * TICKED IS A SOLID DISC WITH A TICK OVER IT, NOT A DONUT. Ring and fill are one
 * colour and a check sits on top. `RadioButtonView` draws exactly that — stroke,
 * then `Circle().fill`, then the glyph — and so does `7670:79936`. A ring with an
 * inner dot and a gap between them is neither reference and is what several
 * apps have shipped instead.
 *
 * `--color-accent-alt-default` AND NOT THE FRAME'S OWN FILL, DELIBERATELY.
 * `7670:79936` binds the ticked circle to `accents/green-400` #289A62, a raw
 * palette entry with no semantic name and no dark counterpart in this token set.
 * `RadioButtonView(.green)` — the component the design points at — resolves to
 * `Button.accentAltDefault`, which is `--color-accent-alt-default` here: #038247
 * in light, #43A774 in dark. Matching the component rather than the one raw fill
 * is what keeps this working in dark at all.
 *
 * THE TICK IS THE DESIGN SYSTEM'S OWN GLYPH, MASKED. `RadioButtonView` puts
 * `Icons.Core.checkFilled16` over the disc, and `7670:79936` puts a 14px
 * `Icons/V2/16px` in a `p-[3px]` circle — the same glyph at the same size, since
 * 20 less 3 of padding on each side is 14. The `d` below is `checkFilled_16.svg`
 * byte for byte; it is a MASK rather than a `background-image` so the ink comes
 * from a token and follows the scheme, and `mask-size: contain` maps the whole
 * 16-unit viewBox onto the 14px box, which is what reproduces the glyph's own
 * insets. Drawing it instead as two rotated borders — which is what Droplets
 * shipped — is a hand-made approximation of a glyph the design system already
 * ships, and it is the half of PI-9852 item 8 that a reader can see.
 *
 * `inset: 1px` IS 14px, AND IT IS ARITHMETIC RATHER THAN A CHOICE. An absolutely
 * positioned child is laid out against its ancestor's PADDING box, and the ring
 * takes 2 off each edge of the 20, so that box is 16. 16 less 1 on each side is
 * the 14 above, centred on the 20 by construction.
 *
 * `--color-background` FOR THE TICK, AND ITS DARK VALUE IS A KNOWN DIVERGENCE.
 * `RadioButtonView` inks the tick `Base.background` — the app's own page colour,
 * so the tick reads as a hole punched in the disc rather than as a second ink.
 * `--color-background` is this package's generated name for that token and its
 * light value #FAF3EA is exactly what `7670:79936` rasterises to. In DARK,
 * plaza's #131212 and iOS's `Base.background` #242322 disagree, and this sheet
 * takes plaza's — the same call `miniapp-tokens.css` documents for the CTA fill,
 * for the same reason: closing that gap is plaza's change to make, not a mini
 * app's. Both values clear 5:1 against the dark disc, so the divergence is a
 * provenance question here and not a legibility one.
 *
 * NO MOTION, ON PURPOSE. The component transitions the fill and the tick in with
 * a scale, and this sheet does not: its first consumer draws forty of these at
 * once on entering a mode, and forty simultaneous scale-ins is a different thing
 * from one radio answering a tap.
 *
 * THE HIT AREA IS THE CONSUMER'S. The Figma instance is a 32x32 box around the
 * 20px circle (`px-[6px]`, and `RadioButtonView` ends `.frame(width: 32,
 * height: 32)`), but 32 is a FLOOR that a row can and should beat, and the way
 * to beat it depends on the row: an absolutely positioned `::before` on the
 * button steals space from whatever is beside it, which only the consumer knows
 * the shape of. So this sheet draws the circle and states the floor, and the
 * consumer buys the area — Droplets' `.msg-pick::before` reaches 32x42 that way.
 */

.pi-radio {
  flex: 0 0 auto;
  display: inline-block;
  position: relative;
  width: 20px;
  height: 20px;
  border: 2px solid var(--color-text-secondary);
  border-radius: var(--pi-radius-round);
}

[aria-checked="true"] .pi-radio {
  border-color: var(--color-accent-alt-default);
  background: var(--color-accent-alt-default);
}

[aria-checked="true"] .pi-radio::after {
  --pi-radio-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23000' fill-rule='evenodd' clip-rule='evenodd' d='M14.5185 3.55856C14.8815 3.08612 14.8007 2.40353 14.3352 2.03248C13.8675 1.65967 13.1915 1.74458 12.8268 2.21919L5.74766 11.4323L3.11392 8.54856C2.71035 8.10669 2.02959 8.08134 1.59527 8.49284C1.16304 8.90236 1.13892 9.58938 1.54057 10.0292L5.03147 13.8514C5.24488 14.085 5.54902 14.2126 5.864 14.199C6.17895 14.1853 6.4711 14.0318 6.66401 13.7808L14.5185 3.55856Z'/%3E%3C/svg%3E");
  content: "";
  position: absolute;
  inset: 1px;
  background-color: var(--color-background);
  -webkit-mask: var(--pi-radio-check) no-repeat center / contain;
  mask: var(--pi-radio-check) no-repeat center / contain;
}


/* ─── wait.css ─── */
/**
 * Wait — Pi's three bouncing dots: the whole-screen wait, and the inline one a busy
 * control wears while it is working.
 *
 *   <div class="pi-wait" role="status">
 *     <span class="pi-dots" aria-hidden="true"><i></i><i></i><i></i></span>
 *     <span class="pi-sr-only">Loading…</span>
 *   </div>
 *
 *   <span class="pi-dots" data-size="inline" aria-hidden="true"><i></i><i></i><i></i></span>
 *
 * Three `<i>` children exactly; the label is IN the document rather than an
 * `aria-label`, so it can be read back.
 *
 * VALUES ARE DROPLETS' OWN, unchanged (`apps/droplets/public/join-view.css`, whose
 * private copy this replaced). They are in design review; publishing the nearest token
 * instead would move reviewed pixels before anyone reviewed them.
 *
 * `data-size` and not the `.inline` / `.inline.tiny` classes it was lifted from — data
 * attributes are this package's variant contract — and each value is SELF-CONTAINED, so
 * `data-size="tiny"` is a whole small inline wait rather than a second attribute.
 *
 * `@keyframes pi-wait-in` ships with no rule using it, deliberately. WHETHER a wait may
 * be drawn at all is the app's call — Droplets holds one back 150ms so a 30-80ms read
 * never draws one — but the curve is the component's, and the alternative is every app
 * writing it again.
 */

/* Pi's loading dots (PI-9134), a port of `Pi_loading_dots.json` at 30fps over 38 frames.
   TWO keyframe sets rather than one on `transform`: the Lottie is two independent tracks
   with their own Beziers, and merged they measured 7.16px out on a 14px rise. */
.pi-wait {
  display: grid;
  place-items: center;
  gap: 0;
  min-height: 40vh;
  color: var(--color-text-accent, #131212);
}

.pi-dots {
  --pi-dot: 10px;
  --pi-dot-gap: 6.1px;
  --pi-dot-row: 24px;
  --pi-dot-peak: -14px;
  display: flex;
  align-items: center;
  gap: var(--pi-dot-gap);
  height: var(--pi-dot-row);
}

.pi-dots i {
  width: var(--pi-dot);
  height: var(--pi-dot);
  border-radius: 50%;
  background: currentColor;
  transform-origin: 50% 100%;
  animation: pi-dots 1267ms infinite, pi-dots-squash 1267ms infinite;
}

/* 5 and 10 frames at 30fps, written TWICE: one value reads as if only one track staggered. */
.pi-dots i:nth-child(2) { animation-delay: 167ms, 167ms; }
.pi-dots i:nth-child(3) { animation-delay: 334ms, 334ms; }

/* `ks.p.y` on frames 5, 14, 21 of 38. */
@keyframes pi-dots {
  0%,      13.158% { translate: 0 0; animation-timing-function: cubic-bezier(.333, 0, 0, 1); }
  36.842%          { translate: 0 var(--pi-dot-peak); animation-timing-function: cubic-bezier(.9, 0, .99, 1); }
  55.263%, 100%    { translate: 0 0; }
}

/* `ks.s` on frames 0, 6, 10, 12, 21, 23, 28. Collapsing 10 and 12 starts it 67ms early. */
@keyframes pi-dots-squash {
  0%               { scale: 1 1;     animation-timing-function: cubic-bezier(.333, 0, .1, 1); }
  15.789%          { scale: 1.1 .9;  animation-timing-function: cubic-bezier(.9, 0, .9, 1); }
  26.316%          { scale: 1 1;     animation-timing-function: cubic-bezier(.114, 0, .1, 1); }
  31.579%          { scale: 1 1;     animation-timing-function: cubic-bezier(1, 0, .846, 1); }
  55.263%          { scale: .8 1.2;  animation-timing-function: cubic-bezier(.333, 0, .1, 1); }
  60.526%          { scale: 1.2 .8;  animation-timing-function: cubic-bezier(.333, 0, 0, 1); }
  73.684%, 100%    { scale: 1 1; }
}

/* Every number is a fraction of the ones above, so it is the same motion. */
.pi-dots[data-size="inline"] {
  display: inline-flex;
  vertical-align: middle;
  --pi-dot: 8px;
  --pi-dot-gap: 4.88px;
  --pi-dot-row: 19.2px;
  --pi-dot-peak: -11.2px;
}

.pi-dots[data-size="tiny"] {
  display: inline-flex;
  vertical-align: middle;
  --pi-dot: 6px;
  --pi-dot-gap: 3.66px;
  --pi-dot-row: 14px;
  --pi-dot-peak: -8.4px;
}

/* After the base rule: a media query adds no specificity. `animation: none` is the
   shorthand, so both tracks go down together and cannot stop on different frames. */
@media (prefers-reduced-motion: reduce) {
  .pi-dots i { animation: none; }
}

/* A wait that is over before you saw it should never have been drawn: 60% of the run is
   spent invisible, so a fast reply never flashes three dots. The rule that applies this
   is the app's — which of its boxes count as a wait is not the component's business. */
@keyframes pi-wait-in {
  0%, 60% { opacity: 0; }
  100%    { opacity: 1; }
}

/**
 * Spinner — the progress ring a BUSY CONTROL wears, as against the three dots above.
 *
 *   <span class="pi-spinner" data-size="inline" aria-hidden="true"></span>
 *
 * TWO COMPONENTS, NOT ONE REPLACING THE OTHER, and which one a surface wants is the
 * question PI-10174 actually asked: *"We used to have a button loading state where it was
 * a progress circle — can we use that instead of the 3 jiggling dots in the button?"*
 * `10:538` in the Consumer library (`Size=Large, Type=Primary, State=Loading`) draws a ring,
 * and `.pi-dots` stays exactly what it was — Pi's own loading dots, PI-9134's port of
 * `Pi_loading_dots.json`, which is right for a whole-screen wait and for a message that has
 * not arrived. A button is neither.
 *
 * ONE ELEMENT, NO CHILDREN, deliberately: `.pi-dots` needs its three `<i>` because each
 * carries its own delay, and a caller who forgets one gets a two-dot wait. A ring has
 * nothing to stagger, so the markup cannot be got wrong.
 *
 * BOTH TONES ARE `currentColor`, which is the half of the report that was a real defect —
 * "sometimes the incorrect color". A busy control is `:disabled`, and the CTA's disabled
 * fill is not its default fill, so any named colour here is one that has to be re-picked
 * per state and per theme and silently is not. Inheriting the control's own ink tracks all
 * of them for free. The track is separated from the arc by ALPHA rather than by a second
 * hue, for the same reason.
 *
 * `border-color` BEFORE `border-top-color`, and a transparent base under both: where
 * `color-mix` is not understood the whole declaration is dropped, which leaves a
 * transparent ring with one solid arc — still unmistakably a spinner, still rotating —
 * rather than the solid circle that a `currentColor` base would degrade into, which reads
 * as not moving at all.
 */
.pi-spinner {
  --pi-ring: 24px;
  --pi-ring-w: 3px;
  display: inline-block;
  vertical-align: middle;
  box-sizing: border-box;
  width: var(--pi-ring);
  height: var(--pi-ring);
  border-radius: 50%;
  border: var(--pi-ring-w) solid transparent;
  border-color: color-mix(in srgb, currentColor 25%, transparent);
  border-top-color: currentColor;
  animation: pi-spinner 900ms linear infinite;
}

/* Sized off `.pi-dots`' own rows (24 / 19.2 / 14), so a control that swaps one for the
   other keeps its line box and nothing below it moves. */
.pi-spinner[data-size="inline"] {
  --pi-ring: 19.2px;
  --pi-ring-w: 2.5px;
}

/* THE CTA PILL'S OWN, AND THE ONLY SIZE READ OFF A FRAME. The rows above are the dots'
   and were inherited for free; this one is `10:538` measured, because a ring at the
   inline row is visibly undersized in a 56px pill and reads as a spare glyph rather than
   as the button working. Decoded off the exported frame: the ring spans x=162..190 on the
   centre row — 29px across, of which 4px each side is solid stroke (161 and 166 are both
   button fill, so the band is exact and not anti-aliasing). 28 is that number without the
   AA edge, and it is also exactly half the pill's 56px height. */
.pi-spinner[data-size="cta"] {
  --pi-ring: 28px;
  --pi-ring-w: 4px;
}

.pi-spinner[data-size="tiny"] {
  --pi-ring: 14px;
  --pi-ring-w: 2px;
}

/* `rotate` and not `transform`, so a caller may still scale or translate the ring. */
@keyframes pi-spinner {
  to { rotate: 360deg; }
}

/* The ring's whole content is its motion, so a reader who has asked for none gets the
   track alone: the arc would otherwise read as a broken circle frozen at 12 o'clock. */
@media (prefers-reduced-motion: reduce) {
  .pi-spinner {
    animation: none;
    border-top-color: color-mix(in srgb, currentColor 25%, transparent);
  }
}


/* ─── toast.css ─── */
/**
 * Toast — the HUD pill that confirms an action, and reports the failure of one.
 *
 *   <div class="pi-toast"><svg …><path class="knock" …/></svg><span>Copied</span></div>
 *
 * One at a time, on `document.body`, removed on a timer. There is no warn VARIANT: the
 * tone is carried by the glyph the app puts inside the pill, and the pill itself is one
 * shape in every tone — which is the reviewed design, not an omission.
 *
 * VALUES ARE DROPLETS' OWN, unchanged (`apps/droplets/public/app.css`, whose private copy
 * this replaced). The shadow is a literal in both schemes on purpose: it is the frame's
 * own, and a HUD's tight shadow does not flip with the palette. The fill/ink pair is the
 * one place a mini app already owns a token — `--miniapp-color-*` — and it is 4.616:1 in
 * dark, so neither half may be swapped for a `--color-*` alone.
 *
 * `--pi-toast-top` IS THE ONE HOOK, and it exists because the distance from the top of
 * the window is host-inset arithmetic the package cannot know: the safe area and the
 * height of Pi's own chrome above the frame. Unset, the pill sits 12px down, which is
 * what the rule computes on a surface with neither.
 *
 * THE EXIT HANGS OFF `[data-closing]` and not the `[data-state="closing"]` that
 * `.pi-dialog-scrim` reserves, because the dialog's attribute carries no rules here — the
 * exit there is the app's — while this component owns both halves of its motion. The
 * consumer marks the node and the pill leaves; Droplets' shared `dismiss()` already
 * writes exactly that attribute. Unifying the two spellings is a change to the dialog.
 */

.pi-toast {
  position: fixed;
  left: 20px;
  right: 20px;
  top: var(--pi-toast-top, 12px);
  max-width: 616px;
  margin-inline: auto;
  display: flex;
  align-items: center;
  gap: 8px;
  z-index: 60;
  background: var(--miniapp-color-button-accent-fill);
  color: var(--miniapp-color-message-text-contrast);
  border-radius: 5px;
  padding: 16px;
  font-size: 16px;
  letter-spacing: var(--pi-track-body);
  line-height: var(--pi-lh-copy);
  box-shadow: 0 3px 4px rgba(0, 0, 0, .44);
  animation: pi-toast-in .22s cubic-bezier(.32, .72, 0, 1) both;
}

.pi-toast[data-closing] {
  animation: pi-toast-in-exit .18s ease-in reverse both;
}

.pi-toast svg {
  flex: none;
  fill: var(--miniapp-color-message-text-contrast);
}

/* The glyph is drawn in the ink and its counter is punched back out in the fill, so the
   pair moves together and the "i" cannot go invisible in one scheme. */
.pi-toast svg .knock {
  fill: var(--miniapp-color-button-accent-fill);
}

/* After the base rules: a media query adds no specificity, and `[data-closing]` is more
   specific than the plain selector, so the exit needs naming here too or it survives this
   block. `animation: none` is the declaration Droplets' own block applied. */
@media (prefers-reduced-motion: reduce) {
  .pi-toast, .pi-toast[data-closing] { animation: none; }
}

/* The exit carries its OWN keyframes name. CSS matches a running animation to the new
   `animation-name` list BY NAME, so a `.closing` rule naming the entry's keyframes starts
   no exit at all — the browser updates the finished entry in place and the fill pins the
   element on the exit's last frame in one frame. `reverse` mirrors the entry's timing
   function; a forward rewrite measured 35px apart over a 100px travel. */
@keyframes pi-toast-in {
  from { opacity: 0; transform: translateY(-10px); }
  to   { opacity: 1; transform: none; }
}

@keyframes pi-toast-in-exit {
  from { opacity: 0; transform: translateY(-10px); }
  to   { opacity: 1; transform: none; }
}


/* ─── sheet.css ─── */
/**
 * Bottom sheet — the overlay card that rises from the foot, and is a centred modal from 768px.
 *
 *   <div class="pi-sheet-scrim">
 *     <div class="pi-sheet" data-variant="tall">
 *       <div class="pi-sheet__head"><span class="pi-sheet__grab"></span>…</div>
 *       <h2 class="pi-sheet__title">…</h2>
 *
 * `data-variant` is `tall` or `actions`, each self-contained. Head and grab are optional: a
 * sheet with no grab is one that cannot be pulled down, which is how an action sheet is spelled.
 * `[data-closing]` on the scrim runs both exits, as `css/toast.css` does.
 *
 * VALUES ARE DROPLETS' OWN, unchanged (`apps/droplets/public/app.css`, whose private copy this
 * replaced) — including `z-index: 40`, a rung on THAT app's ledger, under `.pi-dialog-scrim`'s 50.
 *
 * TWO HOOKS, both host facts. `--pi-sheet-foot`: how tall a band the host draws its own chrome
 * over is arithmetic this package cannot do; unset, the scrim reaches the bottom of the window.
 * `--pi-sheet-surface`: Pi Web's fullscreen presentation re-points the page colour, and the host
 * tells the sheet through this hook; unset, the plate is `--color-default`.
 *
 * THE GRAB IS `display: none` FROM 768px, AND THAT IS LOAD-BEARING — a consumer's drag gate
 * reads the handle's `offsetHeight`, so this is what turns swipe-to-dismiss off on the modal.
 */

.pi-sheet-scrim {
  /* Longhands, not `inset`: a shorthand would re-declare the hook's `bottom` at equal
     specificity and win. */
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: var(--pi-sheet-foot, 0px);
  z-index: 40;
  background: var(--miniapp-color-scrim);
  display: flex;
  align-items: flex-end;
  animation: pi-scrim-in .22s ease both;
}

.pi-sheet-scrim[data-closing] {
  animation: pi-scrim-in-exit .22s ease reverse both;
}

.pi-sheet {
  width: 100%;
  max-height: 92%;
  overflow-y: auto;
  /* `--color-default`, not `--color-background`: the two agree in light and differ in dark
     (#242322 against #131212), and this plate is the app's page colour. It is the FALLBACK
     because a host may re-point that page colour per presentation, and a token cannot follow. */
  background: var(--pi-sheet-surface, var(--color-default));
  border-radius: 20px 20px 0 0;
  padding: 16px 24px 26px;
}

.pi-sheet-scrim > .pi-sheet {
  animation: pi-sheet-up .32s cubic-bezier(.32, .72, 0, 1) both;
}

.pi-sheet-scrim[data-closing] > .pi-sheet {
  animation: pi-sheet-up-exit .24s cubic-bezier(.4, 0, 1, 1) reverse both;
}

/* 65% of the SCREEN — `dvh` on the height — with the cap in `%` of the scrim, which ends
   at the host's foot band. 65dvh against a scrim shortened by 400px of keyboard would
   overflow the top under `flex-end`. */
.pi-sheet[data-variant="tall"] {
  height: 65dvh;
  max-height: min(65dvh, 100%);
}

/* An action card. THE WIDTH IS STATED AND THE INSET IS AN AUTO MARGIN: the scrim is a flex
   ROW, so `width: 100%` plus two 24s would push 48px of card off screen. */
.pi-sheet[data-variant="actions"] {
  width: calc(100% - 48px);
  margin: 0 auto calc(26px + env(safe-area-inset-bottom, 0px));
  border-radius: 20px;
  padding: 25px;
}

/* `position: relative` is the grab's containing block and nothing else; the head has no
   height of its own, so a head with neither control in it collapses. */
.pi-sheet__head {
  display: flex;
  align-items: center;
  position: relative;
}

.pi-sheet__grab {
  position: absolute;
  left: 50%;
  /* 8 below the card's top, against the card's own 16 of padding. */
  top: calc(8px - 16px);
  transform: translateX(-50%);
  width: 34px;
  height: 4px;
  border-radius: 3px;
  background: var(--miniapp-color-grab);
}

/* The drag surface, scoped to the OVERLAY — the same head on a sheet drawn as a PAGE has
   nothing to pull, and needs to scroll. */
.pi-sheet-scrim .pi-sheet__head,
.pi-sheet-scrim .pi-sheet__grab {
  touch-action: none;
}

.pi-sheet__title {
  font-family: var(--pi-font-heading);
  font-size: 24px;
  font-weight: 400;
  letter-spacing: var(--pi-track-heading);
  line-height: 1.1;
  color: var(--color-text-accent);
  margin: 6px 0 16px;
}

@keyframes pi-scrim-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@keyframes pi-scrim-in-exit {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@keyframes pi-sheet-up {
  from { transform: translateY(100%); }
  to   { transform: none; }
}

@keyframes pi-sheet-up-exit {
  from { transform: translateY(100%); }
  to   { transform: none; }
}

/* ── from 768px, a centred modal ────────────────────────────────────────────
   ORDER, NOT SPECIFICITY, decides two of these — `.pi-sheet-scrim > .pi-sheet` ties both
   variants at (0,2,0) — so this block stays LAST in the file. `height` is not contested at
   all, which is why the tall rule below says `auto` out loud. The 48 is the inset a consumer
   draws its desktop card at. */
@media (min-width: 768px) {
  .pi-sheet-scrim {
    align-items: center;
    align-items: safe center;
    justify-content: center;
    box-sizing: border-box;
    padding: 48px 24px;
  }

  .pi-sheet-scrim > .pi-sheet {
    width: 400px;
    max-width: 100%;
    max-height: 100%;
    border-radius: 20px;
    padding: 24px;
  }

  .pi-sheet-scrim > .pi-sheet[data-variant="tall"] {
    height: auto;
  }

  .pi-sheet-scrim .pi-sheet__grab {
    display: none;
  }
}


/* ─── press.css ─── */
/**
 * Long-press surface — the layer a message is held under: a dim, a lifted copy of the
 * message, the emoji bar and the action list.
 *
 *   <div class="press-wrap">                        ← the host's own fixed layer
 *     <div class="pi-press-scrim"></div>
 *     <div class="pi-press-lit"></div>              ← --pi-press-rise is written on it
 *     <div class="pi-press-bar" role="group"><button>…</button>…</div>
 *     <div class="pi-press-menu" role="menu">
 *       <button data-variant="destructive"><span class="pi-press-menu__ic">…</span>Label
 *
 * THE FOUR ARE `absolute` AND THE HOST PLACES THEM: measuring the anchor, cloning the
 * message and solving the vertical layout is arithmetic this package cannot do, so the
 * holder — its `position`, its z-index and where it stops above the host's chrome — stays
 * with the app. `[data-closing]` on that holder runs every exit, as `css/sheet.css` does.
 *
 * VALUES ARE DROPLETS' OWN, unchanged (`apps/droplets/public/app.css`, whose private copy
 * this replaced).
 *
 * `.pi-press-menu` IS THE iOS `.pi-menu` ARM IN A SECOND PLACE, deliberately — one
 * component in two shapes, differing only in the row's entry and its 40px floor.
 * `test/press.test.js` equates the four numbers they share, because drifting apart is
 * exactly what these two did last time.
 *
 * THE iOS PLATE IS NOT HERE. `--ios-glass-*` is one recipe on one site in the app that
 * owns it; a host that draws this surface on iOS restates the material, never the geometry.
 */

.pi-press-scrim {
  position: absolute;
  inset: 0;
  background: var(--miniapp-color-scrim);
  animation: pi-press-dim .2s ease both;
}

.pi-press-lit {
  position: absolute;
  animation: pi-press-lift .3s cubic-bezier(.41, 0, .13, 1) both;
}

.pi-press-bar,
.pi-press-menu {
  position: absolute;
}

/* THE WIDTH IS THE LIST'S LENGTH PLUS A CONSTANT, so adding an emoji widens the bar.
   Nothing caps it and nothing may: a `max-width` would shrink the slots below the 40 that
   makes them a thumb target. */
.pi-press-bar {
  display: flex;
  gap: 0;
  padding: 4px;
  background: var(--miniapp-color-field);
  border: 1px solid var(--color-edge-stroke);
  border-radius: 999px;
  box-shadow: 0 10px 26px var(--miniapp-color-shadow);
  animation: pi-press-grow .28s cubic-bezier(.41, 0, .13, 1) both;
}

.pi-press-bar button {
  width: 40px;
  height: 40px;
  padding: 0;
  display: grid;
  place-items: center;
  background: none;
  border: none;
  border-radius: 50%;
  font-size: 24px;
  line-height: 1;
}

/* A FILL AND NOTHING ELSE — a ring on this platter reads as a second selection.
   `--color-menu-bg-tap` and not the pressed fill below: in dark that one is byte for byte
   the plate it would be painted on, which is an invisible selection. */
.pi-press-bar button[aria-pressed="true"] {
  background: var(--color-menu-bg-tap);
}

.pi-press-menu {
  min-width: 250px;
  padding: 10px 16px;
  background: var(--miniapp-color-field);
  border: 1px solid var(--miniapp-color-border);
  border-radius: 34px;
  box-shadow: 0 12px 30px var(--miniapp-color-shadow);
  animation: pi-press-grow .28s cubic-bezier(.41, 0, .13, 1) .03s both;
}

.pi-press-menu button {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  min-height: 40px;
  padding: 0 8px;
  background: none;
  border: none;
  border-radius: 12px;
  font: inherit;
  font-size: 17px;
  line-height: calc(20 / 17);
  letter-spacing: -0.43px;
  text-align: left;
  color: var(--color-text-accent);
}

.pi-press-menu button[data-variant="destructive"] {
  color: var(--color-error-default);
}

.pi-press-menu button:active {
  background: var(--miniapp-color-tap);
}

.pi-press-menu__ic {
  display: inline-flex;
  flex: 0 0 auto;
  width: 28px;
  justify-content: center;
}

/* ── motion ─────────────────────────────────────────────────────────────────
   Every exit carries its OWN name: CSS matches a running animation to the new
   `animation-name` list BY NAME, so an exit reusing its entry's name starts no exit at
   all — the finished entry is updated in place and the fill pins the element on the
   exit's last frame in one frame. `reverse` mirrors the entry's timing function.

   The lift reads `--pi-press-rise`, the distance the held copy has to travel once the
   host has solved where the bar and the list go. Unset, it lifts in place. */
@keyframes pi-press-dim {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@keyframes pi-press-lift {
  from { transform: translate3d(0, var(--pi-press-rise, 0px), 0) scale(.95); }
  to   { transform: none; }
}

@keyframes pi-press-grow {
  from { opacity: 0; transform: scale(.88); }
  to   { opacity: 1; transform: none; }
}

@keyframes pi-press-grow-exit {
  from { opacity: 0; transform: scale(.88); }
  to   { opacity: 1; transform: none; }
}

@keyframes pi-press-fade-exit {
  from { opacity: 0; }
  to   { opacity: 1; }
}

[data-closing] .pi-press-scrim {
  animation: pi-press-fade-exit .18s ease reverse both;
}

[data-closing] .pi-press-lit {
  animation: pi-press-fade-exit .16s ease-in reverse both;
}

/* Both platters leave together from WHICHEVER holder is closing — the emoji bar is drawn
   inside the ••• menu's holder as well as this one, and the app that reuses it that way
   had to name the second holder by hand. */
[data-closing] .pi-press-bar,
[data-closing] .pi-press-menu {
  animation: pi-press-grow-exit .16s ease-in reverse both;
}


/* ─── controls.css ─── */
/**
 * The three controls Pi's component set had no counterpart for, plus the chip row.
 *
 *   <button class="pi-text-btn">Cancel</button>
 *   <textarea class="pi-field" data-variant="app"></textarea>
 *   <div class="pi-counter">12/280</div>
 *   <p class="pi-field-error" hidden><svg …/>That name is too long</p>
 *   <div class="pi-chips"><span class="pi-chip" data-variant="app">…</span>…</div>
 *
 * THE VARIANTS OF EXISTING ROOTS ARE NOT HERE — `.pi-btn[data-size="cta"]`,
 * `.pi-icon-btn[data-size="disc"]`, `.pi-chip[data-variant="app"]` and
 * `.pi-field[data-variant="app"]` live beside their defaults in `css/components.css`,
 * because each file here is the sole owner of its selector roots. This sheet holds only
 * roots that are new.
 *
 * `.pi-counter` and `.pi-field-error` are a FIELD'S two captions and both stand outside
 * it: the counter sits under a box it does not know the height of, and the error line
 * carries the app's own inline glyph. `.pi-dialog__field` in `css/components.css` is the
 * flex column that spaces them when they are inside a modal.
 *
 * VALUES ARE DROPLETS' OWN, unchanged (`apps/droplets/public/app.css`, whose private copy
 * this replaced).
 */

/* A SIBLING OF `.pi-icon-btn`, NOT A MODIFIER OF IT: that would clip a word into a disc.
   The 40px floor is the head disc's own height — a 44 here moves every header row by four
   pixels — and `::after` buys the thumb the rest. */
.pi-text-btn {
  flex: 0 0 auto;
  position: relative;
  min-height: 40px;
  padding: 0 4px;
  border: 0;
  background: transparent;
  display: inline-flex;
  align-items: center;
  white-space: nowrap;
  cursor: pointer;
  font-family: inherit;
  font-size: 16px;
  font-weight: 500;
  letter-spacing: var(--pi-track-body);
  line-height: var(--pi-lh-single);
  color: var(--color-text-secondary);
  border-radius: 12px;
}

.pi-text-btn::after {
  content: "";
  position: absolute;
  inset: -2px -8px;
}

/* `#base/text-secondary` and NOT the ink a dead control wears: a live character count is
   not disabled. The 22px foot is the gap to whatever follows the field. */
.pi-counter {
  text-align: right;
  font-size: 14px;
  color: var(--miniapp-color-muted);
  margin: 4px 0 22px;
}

.pi-field-error {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 14px;
  line-height: var(--pi-lh-copy);
  letter-spacing: var(--pi-track-tight);
  color: var(--color-error-default);
  margin: 4px 0 0;
}

/* THIS LINE IS THE FIX, NOT THE DISEASE: the `display: flex` above beats the UA's
   `[hidden]`, so without this a field with no error shows an empty red row. */
.pi-field-error[hidden] {
  display: none;
}

/* The glyph is the app's own inline SVG; the gap above reserves room for one. */
.pi-field-error svg {
  fill: var(--color-error-default);
  flex: none;
}

/* A WRAPPING row of chips. The horizontally scrolling strip is a different component with
   its own hit-testing and foot reservation, and is not this. */
.pi-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 22px;
}


/* ─── surfaces.css ─── */
/**
 * The flat surfaces — boxes that hold content and have no state.
 *
 *   <div class="pi-notice"><span>Pi renamed the group “silly geese”</span></div>
 *   <div class="pi-banner">We couldn't reach the group. Try again.</div>
 *   <span class="pi-badge">Small groups</span>
 *
 * The notice is a LABELLED DIVIDER: the two rules under it draw the line, so the `<span>`
 * is required — a bare text child gets no ellipsis and no rules either side of it.
 *
 * The banner animates itself in. Its keyframe ships here with the rule that uses it, and a
 * consumer that re-triggers the entry on a screen swap re-declares `animation` at a higher
 * specificity rather than reaching for a second name.
 *
 * VALUES ARE DROPLETS' OWN, unchanged (`apps/droplets/public/app.css`, whose private copy
 * this replaced). No hooks: nothing here depends on a host inset.
 */

.pi-notice {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 4px 0 16px;
  padding: 6px 0;
  font-size: 14px;
  color: var(--miniapp-color-muted);
  white-space: nowrap;
  letter-spacing: var(--pi-track-tight);
  line-height: var(--pi-lh-copy);
  overflow: hidden;
}

.pi-notice > span {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

.pi-notice::before,
.pi-notice::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--color-divider-stroke);
}

/* The amber trio has no upstream home at all — see `css/miniapp-tokens.css`. */
.pi-banner {
  background: var(--miniapp-color-amber-fill);
  border: 1px solid var(--miniapp-color-amber-stroke);
  color: var(--miniapp-color-amber-ink);
  border-radius: 12px;
  padding: 11px 13px;
  font-size: 14px;
  line-height: var(--pi-lh-copy);
  margin-bottom: 14px;
  animation: pi-banner-in .2s cubic-bezier(.32, .72, 0, 1) both;
}

@keyframes pi-banner-in {
  from { opacity: 0; transform: translate3d(0, -8px, 0); }
  to   { opacity: 1; transform: none; }
}

/* `--color-default` and not `--color-background`: the two agree in light and differ in dark
   (#242322 against #131212), and this pill is punched out of the page, not out of the app's
   darkest ground. Same choice `css/sheet.css` makes for the plate. */
.pi-badge {
  display: inline-block;
  padding: 4px 8px;
  border-radius: 999px;
  border: 1px solid var(--miniapp-color-badge-stroke);
  background: var(--color-default);
  color: var(--miniapp-color-muted);
  font-size: 12px;
  line-height: var(--pi-lh-copy);
  letter-spacing: var(--pi-track-body);
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  vertical-align: top;
}


/* ─── preview.css ─── */
/**
 * The link preview — a bordered row: a tinted thumbnail, a title, and the shortened URL.
 *
 *   <div class="pi-preview" data-size="big">
 *     <span class="pi-preview__thumb"><img src="…" alt=""></span>
 *     <div>
 *       <div class="pi-preview__title">Between us</div>
 *       <div class="pi-link-url">example.com/j/abc</div>
 *     </div>
 *   </div>
 *
 * `data-size="big"` is the share screen's larger shape; omit it for the inline row. Both
 * sizes are self-contained — `big` restates only what it changes, because it is a size on
 * one component and not a second component.
 *
 * `.pi-link-url` is separable: it is the shortened link's typography wherever a link is
 * shown, and only the ellipsis rule below is the preview's. The row's WIDTH is not the
 * component's — a screen that caps a preview to its own measure says so itself.
 *
 * `--pi-preview-swatch` is the one hook: the thumbnail is tinted per activity, which is data
 * this package cannot know. Unset it is #EA79A1, the fallback Droplets shipped.
 *
 * VALUES ARE DROPLETS' OWN, unchanged (`apps/droplets/public/app.css`, whose private copy
 * this replaced) — including the thumbnail's LITERAL ink: it is a fixed charcoal on a fixed
 * tint and does not theme, exactly as the iOS `activityLeading` seat does not.
 */

.pi-preview {
  display: flex;
  align-items: center;
  gap: 16px;
  background: var(--miniapp-color-field);
  border: 1px solid var(--miniapp-color-border);
  border-radius: 14px;
  padding: 12px 14px;
  margin-top: 12px;
}

.pi-preview__thumb {
  flex: none;
  width: 46px;
  height: 46px;
  border-radius: 10px;
  background: var(--pi-preview-swatch, #EA79A1);
  color: #131212;
  display: grid;
  place-items: center;
}

.pi-preview__title {
  font-size: 14px;
  font-weight: 500;
  letter-spacing: var(--pi-track-body);
  line-height: var(--pi-lh-copy);
  color: var(--color-text-accent);
}

.pi-link-url {
  font-size: 14px;
  color: var(--miniapp-color-muted);
  letter-spacing: var(--pi-track-tight);
}

/* Only inside the row: a link shown on its own line is allowed to wrap. */
.pi-preview .pi-link-url {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pi-preview[data-size="big"] {
  align-items: center;
  padding: 16px;
  border-radius: 10px;
}

.pi-preview[data-size="big"] .pi-preview__thumb {
  width: 80px;
  height: 80px;
}

.pi-preview[data-size="big"] .pi-preview__title {
  font-size: 16px;
  margin-bottom: 2px;
}


/* ─── person-row.css ─── */
/**
 * The roster row — avatar, name, detail, trailing control.
 *
 *   <div class="pi-roster">
 *     <div class="pi-person-row">
 *       <span class="pi-avatar" data-ink="seat">J</span>
 *       <div>
 *         <div class="pi-person-row__name">Jae</div>
 *         <div class="pi-person-row__detail">You</div>
 *       </div>
 *       <button class="pi-icon-btn" aria-label="More about Jae">⋮</button>
 *     </div>
 *   </div>
 *
 * THREE GRID COLUMNS, AND THE FIRST ONE IS A WIDTH, NOT A CONTENT SIZE: the leading cell is
 * 46px (36px inside `.pi-roster`) whatever goes in it, so rows line up when one member has a
 * seat disc and another has an image. The middle cell is the only flexible one — the name
 * and its detail are one node so they stack — and the trailing cell is `auto`, i.e. the
 * control's own box.
 *
 * `.pi-person-row` bare is the BORDERED list: rows are separated by a hairline and the first
 * one has none. `.pi-roster` is the other arrangement — bare rows at a 32px pitch, no rules —
 * and it restates all four properties it changes so the two cannot half-apply.
 *
 * VALUES ARE DROPLETS' OWN, unchanged (`apps/droplets/public/app.css`, whose private copy
 * this replaced). Nothing here styles the avatar or the control: a consumer composes those
 * from `css/avatar.css` and `css/components.css`, and a roster whose leading cell is the
 * host's own artwork sizes it itself.
 */

.pi-person-row {
  display: grid;
  grid-template-columns: 46px 1fr auto;
  gap: 12px;
  padding: 13px 14px;
  align-items: center;
  border-top: 1px solid var(--miniapp-color-border);
}

.pi-person-row:first-child {
  border-top: none;
}

.pi-person-row__name {
  font-size: 16px;
  font-weight: 500;
  line-height: var(--pi-lh-single);
}

.pi-person-row__detail {
  font-size: 12px;
  color: var(--miniapp-color-muted);
  line-height: var(--pi-lh-single);
  margin-top: 2px;
}

.pi-roster {
  display: flex;
  flex-direction: column;
  gap: 32px;
  margin: 0 0 12px;
}

.pi-roster .pi-person-row {
  grid-template-columns: 36px 1fr auto;
  gap: 8px;
  padding: 0;
  border-top: none;
}
