/* ==========================================================================
   Korat web app — house styling.

   Layout discipline (not appearance) borrowed from the mainstream web apps:
   a global app bar, a sticky navigation rail, ONE readable content column
   capped at 640px, and a right rail that only exists when it has something
   worth carrying. Everything lives inside a centred 1280px container so the
   page does not sprawl across a 27" monitor.

   Breakpoints (SHELL section owns these; see "page grid" for the arithmetic):
     < 640      one column + bottom tab bar (matches the Android app)
     640-1023   icon rail + content
     1024-1279  icon rail + content + right rail
     1280-1511  labelled rail + content + right rail
     1512-1919  the same three, each one step wider
     >= 1920    the widest step; the shell stops growing here on purpose

   The density values are the ones in CLAUDE.md, converted 1dp -> 1px:
     list row padding 10/7 · row gap 6 · list side padding 16 · avatar 40
     radius: 16 cards · 12-14 rows/fields · 10 chips · 22 buttons
   From 640px up those steps up exactly once — the ladder keeps its
   proportions, no new values are invented.

   Status colours are literal on purpose: they must never re-skin.

   THEME (2026-08-14, "v3"): the brand tokens (--a1/--a2/--a3, --brand,
   --brand-glow) and the frosted-glass surfaces come from demo/social/v3.
   What was NOT taken from v3, and why, so nobody re-imports it:
     · v3's --text-muted #9CA3AF / --text-dim #6B7280 measure below WCAG AA on
       both themes. --dim/--dim2 keep this file's own values.
     · v3's radius ladder (20/14/8) is a one-page demo's; this file's
       8/10/12/16/22 stays, because the hierarchy is what the owner asked for,
       not the numbers, and 5,800 lines of screens depend on the ladder.
     · v3's shell (fixed 1280, 210/1fr/340) is a single breakpoint set; this
       file's five-step grid stays.
   The glass is now TOKENS (--glass-*), not literals, so the light theme can
   invert it. Never hardcode an rgba surface here again: a literal cannot flip,
   and every one that existed made the light theme wrong in a way that only
   showed up on somebody's screen.
   ========================================================================== */

:root {
  color-scheme: dark;
  /* --------------------------------------------------------------------
     Spacing — a 4px grid system
     -------------------------------------------------------------------- */
  --sp-1: 2px;
  --sp-2: 4px;
  --sp-3: 6px;
  --sp-4: 8px;
  --sp-5: 12px;
  --sp-6: 16px;
  --sp-7: 24px;
  --sp-8: 32px;

  /* --------------------------------------------------------------------
     Typography hierarchy
     -------------------------------------------------------------------- */
  --fs-micro: 11px;
  --fs-sm:    12px;
  --fs-base:  13px;
  --fs-row:   14px;
  --fs-body:  15px;
  --fs-title: 17px;
  --fs-h:     20px;
  --fs-hero:  30px;

  /* --------------------------------------------------------------------
     Radius system
     -------------------------------------------------------------------- */
  --r-xs:   8px;
  --r-sm:   10px;
  --r-md:   12px;
  --r-lg:   16px;
  --r-xl:   22px;
  --r-btn:  24px;
  --r-pill: 999px;

  /* --------------------------------------------------------------------
     Elevation & Glassmorphism Ladder (Dark Luxury Theme)
     -------------------------------------------------------------------- */
  --field: #040409;
  --bg:   #090A14;
  --s1:   #0F101E;
  --s2:   #151627;
  --s3:   #1E1F36;
  --line: rgba(255, 255, 255, 0.08);
  --line-strong: rgba(255, 255, 255, 0.14);

  /* --------------------------------------------------------------------
     GLASS SURFACES — the frosted ladder, as TOKENS.

     These ten values were hardcoded as literal dark rgba() at their call
     sites (.rail .railcard .colhead .tabbar .segs .composer .post .sk-post
     .toast .menu) and the three input tints as literal white rgba(). A
     literal cannot flip, so in LIGHT theme the post card was a navy pane,
     the app bar was black and every field was white-on-white — the app had
     two themes and the glass had one. Tokenised here so the light block
     below can invert them once instead of at thirteen call sites.

     `--glass-*` are SURFACES (they sit under blur); `--s1/--s2/--s3` stay
     the opaque ladder. Neither replaces the other.
     -------------------------------------------------------------------- */
  --glass-card:   rgba(15, 16, 30, 0.70);   /* card / rail / composer / post */
  --glass-card-2: rgba(15, 16, 30, 0.65);   /* the right-rail card, one step lighter */
  --glass-float:  rgba(15, 16, 30, 0.92);   /* toast + menu: must stay legible over anything */
  --glass-header: rgba(9, 10, 20, 0.85);    /* app bar / column head / tab bar */
  --glass-seg:    rgba(21, 22, 39, 0.80);   /* the segmented control's trough */
  --glass-input:       rgba(255, 255, 255, 0.04);
  --glass-input-hover: rgba(255, 255, 255, 0.06);
  --glass-input-focus: rgba(255, 255, 255, 0.08);

  /* Text colors */
  --ink:  #F8F9FC;
  --dim:  #A8B0CC;
  --dim2: #8088A8;

  /* Accent & Gradients */
  --a1: #FF3B88;
  --a2: #8B3DFF;
  --a3: #3B82F6;
  --brand: linear-gradient(135deg, #FF3B88 0%, #8B3DFF 52%, #3B82F6 100%);
  --brand-hover: linear-gradient(135deg, #FF5297 0%, #9B52FF 52%, #5293FF 100%);
  --brand-soft: linear-gradient(135deg, rgba(255,59,136,.14), rgba(139,61,255,.16) 52%, rgba(59,130,246,.14));
  --brand-glow: 0 4px 20px rgba(139, 61, 255, 0.35);
  --on-accent: #FFFFFF;

  /* Interaction tints */
  --hover: rgba(255, 255, 255, 0.06);
  --sel:   linear-gradient(135deg, rgba(255, 59, 136, 0.16), rgba(139, 61, 255, 0.20));

  /* Status colors */
  --amber:  #F59E0B;
  --blue:   #3B82F6;
  --purple: #8B5CF6;
  --sky:    #0EA5E9;
  /* #22C55E, not #10B981. CLAUDE.md and the Android app both carry #22C55E as
     "done / verified"; the emerald was a web-only drift, and two rules in this
     file (`.roomcell.free`) had already hardcoded the correct value beside the
     token — i.e. the same meaning was painted in two greens on one page. */
  --green:  #22C55E;
  --red:    #EF4444;

  /* Ambient Glass & Drop Shadows */
  --shadow: 0 20px 50px -15px rgba(0, 0, 0, 0.9);
  --card-shadow: 0 4px 20px -4px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.07);
  --col-shadow: 0 0 48px -12px rgba(0, 0, 0, 0.75);
  --font: "Noto Sans Thai", "Sora", system-ui, -apple-system, sans-serif;

  /* Component Dimensions */
  --appbar-h: 54px;
  --colhead-h: 46px;
  --tabbar-h: 60px;

  /* Safe Area & Overlay Bounds */
  --safe-t: env(safe-area-inset-top, 0px);
  --safe-b: env(safe-area-inset-bottom, 0px);
  --safe-l: env(safe-area-inset-left, 0px);
  --safe-r: env(safe-area-inset-right, 0px);
  --overlay-max-h: calc(100dvh - var(--safe-t) - var(--safe-b) - 16px);
  --pricecol-w: 92px;

  /* Shell Layout Arithmetic */
  --col-w:     640px;
  --rail-w:    56px;
  --aside-w:   0px;
  --shell-cols: 2;
  --shell-gap: 16px;
  --shell-pad: 12px;
  --shell-w: calc(
    var(--rail-w) + var(--col-w) + var(--aside-w)
    + (var(--shell-cols) - 1) * var(--shell-gap)
    + 2 * var(--shell-pad));

  /* Rail internals. The item is 44px because that is the touch minimum and
     the icon rail is exactly one item wide plus its own padding. */
  --rail-pad: 6px;
  --rail-item-h: 44px;

  /* Native chrome — scrollbars, form controls, autofill, the caret. Without
     this the browser paints all of them in LIGHT while the app is dark: the
     app had two themes and the scrollbar had one. */
  color-scheme: dark;
}

/* Light theme. `data-theme` on <html> always wins over the OS preference so the
   in-app toggle works in both directions.
   Designed, not derived: the page sits on a tinted grey so white cards have
   something to lift off — that separation is the whole job of the ladder. */
/* In light theme s1 and s2 are BOTH #FFFFFF on purpose: light inverts the
   luminance of the ladder (raised = whiter) and there is nothing above white,
   so separation is carried by the hairline instead. Every card that nests a
   row already makes it transparent (`.railcard .row`, `.docklist .row`), so
   the two rungs never actually stack.

   `--dim`/`--dim2` ARE changed: `--dim2` measured 3.1:1 on this background and
   3.5:1 on a white card — it carries timestamps, `.state .detail`, `.row .meta`
   and `.pcard .by`, i.e. real sentences at 11-12px, and it failed WCAG AA on
   every surface in BOTH themes. Both ladders now read ~16 / ~8 / ~5 and the
   worst case is 4.6:1. `--dim` moved with it so the three tiers stay three
   tiers rather than collapsing into two. */
/* `--field` is two steps below `--bg` here rather than one. It used to be
   #E6E4F0 against a #F2F1F8 column — twelve units apart, which on a bright
   monitor is no edge at all, so the desktop composition ("three objects on a
   field") existed in the stylesheet and not on the screen. Dark got the
   separation for free (#06060D under #0B0B14 reads, because the eye is
   working near black); light needed it stated. */
@media (prefers-color-scheme: light) {
  :root[data-theme="auto"] {
    color-scheme: light;
    --field: #DCD9EC;
    --bg: #F2F1F8; --s1: #FFFFFF; --s2: #FFFFFF; --s3: #EBE9F5; --line: #E2DFEE;
    --line-strong: #C9C4DC;
    --ink: #14101F; --dim: #4A4560; --dim2: #69637F;
    --glass-card: #FFFFFF; --glass-card-2: #FFFFFF; --glass-float: #FFFFFF;
    --glass-header: rgba(255, 255, 255, 0.88);
    --glass-seg: #EBE9F5;
    --glass-input: rgba(20, 16, 31, 0.04);
    --glass-input-hover: rgba(20, 16, 31, 0.07);
    --glass-input-focus: rgba(20, 16, 31, 0.10);
    --hover: color-mix(in srgb, var(--ink) 7%, transparent);
    --sel:   color-mix(in srgb, var(--a2) 15%, transparent);
    --brand-soft: linear-gradient(105deg, rgba(255,61,139,.10), rgba(139,61,255,.10) 52%, rgba(61,123,255,.10));
    --shadow: 0 14px 34px -22px rgba(20,16,31,.35);
    --card-shadow: 0 1px 2px rgba(20,16,31,.05), 0 1px 3px rgba(20,16,31,.04);
    --col-shadow: 0 1px 20px -8px rgba(20,16,31,.16);
  }
}
:root[data-theme="light"] {
  color-scheme: light;
  --field: #DCD9EC;
  --bg: #F2F1F8; --s1: #FFFFFF; --s2: #FFFFFF; --s3: #EBE9F5; --line: #E2DFEE;
  --line-strong: #C9C4DC;
  --ink: #14101F; --dim: #4A4560; --dim2: #69637F;
  --glass-card: #FFFFFF; --glass-card-2: #FFFFFF; --glass-float: #FFFFFF;
  --glass-header: rgba(255, 255, 255, 0.88);
  --glass-seg: #EBE9F5;
  --glass-input: rgba(20, 16, 31, 0.04);
  --glass-input-hover: rgba(20, 16, 31, 0.07);
  --glass-input-focus: rgba(20, 16, 31, 0.10);
  --hover: color-mix(in srgb, var(--ink) 7%, transparent);
  --sel:   color-mix(in srgb, var(--a2) 15%, transparent);
  --brand-soft: linear-gradient(105deg, rgba(255,61,139,.10), rgba(139,61,255,.10) 52%, rgba(61,123,255,.10));
  --shadow: 0 14px 34px -22px rgba(20,16,31,.35);
  --card-shadow: 0 1px 2px rgba(20,16,31,.05), 0 1px 3px rgba(20,16,31,.04);
  --col-shadow: 0 1px 20px -8px rgba(20,16,31,.16);
}
/* `data-theme="dark"` is the base :root block, but it must also win over the
   OS when the OS says light — otherwise the in-app toggle only works one way.
   (It already did, by specificity; stating color-scheme here is what makes the
   NATIVE controls follow the override too.) */
:root[data-theme="dark"] { color-scheme: dark; }

/* --------------------------------------------------------------------------
   TOKEN BRIDGE — v3.css's variable names, pointed at THIS app's themed values.

   v3.css (a byte-for-byte copy of demo/social/v3/style.css, imported into
   `layer(v3)` by index.html) writes its colours as --bg-app / --bg-card /
   --text-main / --border / --brand-gradient …, and it declares those in its own
   `:root` plus a `[data-theme="light"]` block. That light block is the problem
   this bridge exists to solve: it only fires on an EXPLICIT `data-theme="light"`.
   The default here is `data-theme="auto"`, so a reader on a light OS who never
   touched the toggle would have had app.css go light while every v3 rule stayed
   on v3's dark values — half a page in each theme, and the owner uses light.

   Re-declaring the names here fixes all three theme states at once, because the
   values are `var(…)` references into the ladder that the blocks above already
   theme (auto+OS-light, explicit light, explicit dark). One block, no copy of
   v3's palette to drift out of date, and NOTHING invented: --dim/--dim2 are used
   for v3's muted/dim precisely because v3's own #9CA3AF/#6B7280 fail WCAG AA
   (see the file header). This is unlayered, so it beats v3's `:root` AND its
   `[data-theme="light"]` regardless of specificity.

   --font-display, --brand-hover, --brand-glow, --card-shadow are deliberately
   NOT listed: the first has no counterpart here (v3's own value stands), the
   other three are already declared in the :root above and therefore already win.
   -------------------------------------------------------------------------- */
:root {
  --bg-app:         var(--bg);
  --bg-card:        var(--glass-card);
  --bg-card-hover:  var(--s2);
  --bg-input:       var(--glass-input);
  --bg-input-focus: var(--glass-input-focus);
  --bg-rail:        var(--glass-card);
  --bg-header:      var(--glass-header);

  --border:        var(--line);
  --border-strong: var(--line-strong);
  --border-focus:  color-mix(in srgb, var(--a2) 50%, transparent);

  --text-main:  var(--ink);
  --text-muted: var(--dim);
  --text-dim:   var(--dim2);

  --accent-pink:   var(--a1);
  --accent-purple: var(--a2);
  --accent-blue:   var(--a3);
  --accent-green:  var(--green);
  --brand-gradient: var(--brand);
  --modal-shadow:   var(--shadow);

  /* v3's 8/14/20 ladder mapped onto this file's 8/12/16 one. The hierarchy is
     what carries over, not the numbers — 5,800 lines of screens are built on
     the ladder here, so a v3 card must land on the same corner as its neighbour
     rather than 4px rounder. */
  --radius-sm:   var(--r-xs);
  --radius-md:   var(--r-md);
  --radius-lg:   var(--r-lg);
  --radius-pill: var(--r-pill);

  --font-main: var(--font);
}

* { box-sizing: border-box; }
html, body { height: 100%; }
body {
  margin: 0;
  /* On a phone the app IS the page, so the page is --bg. From 640px up the
     shell repaints itself onto --field and the content column becomes a real
     surface; see `.page` / `.main` below. */
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--fs-body);
  line-height: 1.55;
  overflow-x: hidden;              /* the body must never scroll sideways */
  -webkit-text-size-adjust: 100%;
  -webkit-font-smoothing: antialiased;
}
@media (min-width: 640px) {
  body { background: var(--field); }
}
a { color: inherit; text-decoration: none; }
button, input, textarea, select { font: inherit; color: inherit; }
img { max-width: 100%; display: block; }
[hidden] { display: none !important; }

/* Keyboard users must always be able to see where they are. Nothing in this
   file removes an outline without putting one back. */
:where(a, button, input, textarea, select, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--a2);
  outline-offset: 2px;
}

/* ---------- boot ---------- */
.boot { min-height: 100dvh; display: grid; place-items: center; gap: var(--sp-5); align-content: center; }
/* THE BRAND MARK IS `/logo.png`, and it is the only one. It is what the worker's
   share and download pages render, what the favicon and the app icons are cut
   from, and what the owner recognises as Korat.

   It is NOT `korat-mark.svg`. That file was derived from the Android launcher
   icon and shipped in the same sync as this stylesheet — the first deploy in
   which the asset had ever existed — so it went live having been reviewed by
   nobody, and the owner's first sight of it was on their own header, asking
   where the logo had come from. The file is left in the tree; nothing uses it.
   The general rule it leaves behind: AN ASSET THAT APPEARS IN A DEPLOY FOR THE
   FIRST TIME IS A VISUAL CHANGE, and a diff of text files does not show it.

   Layered so the gradient stays the fallback: if the PNG ever 404s the mark
   degrades to the brand wash rather than to a hole. `/logo.png` is opaque, so
   in the normal case nothing shows through. The path is root-absolute on
   purpose — this app is served from /app/, the mark from the site root. */
.boot-mark { width: 54px; height: 54px; border-radius: var(--r-lg);
  background: url("/logo.png") center / cover no-repeat, var(--brand);
  animation: pulse 1.4s ease-in-out infinite; }
.boot-text { color: var(--dim); font-size: var(--fs-base); margin: 0; }
@keyframes pulse { 0%,100% { opacity: .45; transform: scale(.94); } 50% { opacity: 1; transform: scale(1); } }
@media (prefers-reduced-motion: reduce) { .boot-mark { animation: none; } }

/* ==========================================================================
   Shell — app bar, three-column page, rails
   ========================================================================== */
.shell { min-height: 100dvh; display: flex; flex-direction: column; }

/* Clipped rather than parked off-screen: `inset-inline-start: -9999px` becomes
   RIGHT: -9999px under RTL, which widens the page and breaks the one rule this
   whole file answers to. Clipping is direction-agnostic. */
.skip {
  position: absolute; inset-inline-start: 8px; top: 8px; z-index: 100;
  width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
  border: 0; font: inherit; cursor: pointer; padding: 0;
}
.skip:focus {
  width: auto; height: auto; overflow: visible; clip-path: none;
  padding: 8px var(--sp-5); border-radius: var(--r-md);
  background: var(--s3); color: var(--ink); box-shadow: var(--shadow);
}
/* The skip link focuses `#main` programmatically, and a ring around the whole
   column on a mouse click reads as a glitch — but a blanket `outline: none` on
   :focus would also swallow a genuine keyboard focus. Scoped to the
   non-keyboard case, so the rule that removes it can never hide a real one. */
.main:focus:not(:focus-visible) { outline: none; }

/* ---------- global app bar ---------- */
.appbar {
  position: sticky; top: 0; z-index: 45;
  background: color-mix(in srgb, var(--bg) 86%, transparent);
  backdrop-filter: blur(14px);
  border-bottom: 1px solid var(--line);
}
/* Same max-width AND the same side padding as `.page`, so the logo starts on
   the rail's edge and the avatar ends on the right rail's edge. It did not
   before: the bar used a 12px gutter against the grid's 16px, so the whole top
   of the page sat four pixels off everything under it at three of the five
   breakpoints — small enough that nobody could name it, big enough to read as
   "nothing lines up". */
.appbar-in {
  max-width: var(--shell-w); margin: 0 auto; min-height: var(--appbar-h);
  display: flex; align-items: center; gap: var(--sp-4); padding-inline: var(--shell-pad);
}
.appbar .logo {
  display: flex; align-items: center; gap: var(--sp-4); flex: 0 0 auto;
  padding: var(--sp-2) var(--sp-4); border-radius: var(--r-md);
  font-weight: 800; font-size: var(--fs-title); letter-spacing: -.01em;
  transition: all 0.2s ease;
}
.appbar .logo:hover { background: var(--hover); transform: translateY(-1px); }
.appbar .logo .mark {
  width: 28px; height: 28px; border-radius: var(--r-xs); flex: 0 0 auto;
  background: url("/logo.png") center / cover no-repeat, var(--brand);
  box-shadow: 0 2px 10px rgba(139, 61, 255, 0.35);
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.appbar .logo:hover .mark { transform: scale(1.06); box-shadow: 0 4px 16px rgba(139, 61, 255, 0.5); }
.appbar .logo .word { display: none; background: var(--brand); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
@media (min-width: 420px) { .appbar .logo .word { display: inline; } }
.appbar .actions { display: flex; align-items: center; gap: 4px; flex: 0 0 auto; }
/* The theme switch is the FOURTH control in this bar (language, theme, bell,
   avatar). At 360px that leaves the search box about 90px wide, i.e. unusable —
   so on the narrowest phones it stands down and the account menu keeps being
   its home, which is where it has always been. It is not removed anywhere: the
   menu row and this button are the same `cycleTheme()`. */
.appbar [data-themetoggle] { display: none; }
@media (min-width: 480px) { .appbar [data-themetoggle] { display: inline-grid; } }

/* ---------- global search ---------- */
.searchbox { flex: 1 1 auto; min-width: 0; max-width: 440px; margin-inline: auto; position: relative; }
.searchbox input {
  width: 100%; padding: var(--sp-4) var(--sp-5); padding-inline: 38px 34px;
  border-radius: var(--r-pill); background: var(--glass-input); border: 1px solid var(--line);
  color: var(--ink); outline: none; font-size: var(--fs-row);
  transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
}
.searchbox input:hover { border-color: rgba(139, 61, 255, 0.4); background: var(--glass-input-hover); }
.searchbox input:focus-visible {
  border-color: var(--a2);
  background: var(--glass-input-focus);
  outline: none;
  box-shadow: 0 0 16px rgba(139, 61, 255, 0.3), 0 0 0 2px var(--a2);
}
.searchbox input::placeholder { color: var(--dim2); }
.searchbox .mag {
  position: absolute; inset-inline-start: 12px; top: 50%; transform: translateY(-50%);
  width: 18px; height: 18px; color: var(--dim2); pointer-events: none;
  transition: color 0.2s ease;
}
.searchbox input:focus ~ .mag { color: var(--a2); }
.searchbox .clear {
  position: absolute; inset-inline-end: 6px; top: 50%; transform: translateY(-50%);
  width: 26px; height: 26px; border: 0; border-radius: var(--r-pill); cursor: pointer;
  background: transparent; color: var(--dim2); display: grid; place-items: center; font-size: var(--fs-base);
  transition: all 0.18s ease;
}
.searchbox .clear:hover { background: var(--s3); color: var(--ink); transform: translateY(-50%) scale(1.1); }

/* ---------- page grid ---------- */
.page { width: 100%; max-width: var(--shell-w); margin: 0 auto; display: grid;
  grid-template-columns: minmax(0, 1fr); }
@media (min-width: 640px) {
  :root { --rail-w: 56px; --shell-cols: 2; --shell-gap: 16px; --shell-pad: 12px; }
  .page { grid-template-columns: var(--rail-w) minmax(0, var(--col-w)); gap: var(--shell-gap);
    padding-inline: var(--shell-pad); justify-content: center;
    min-height: calc(100dvh - var(--appbar-h)); }
}
@media (min-width: 1024px) {
  :root { --rail-w: 56px; --aside-w: 280px; --shell-cols: 3;
          --shell-gap: 12px; --shell-pad: 12px; }
  .page { grid-template-columns: var(--rail-w) minmax(0, var(--col-w)) var(--aside-w); }
}
@media (min-width: 1280px) {
  :root { --rail-w: 240px; --aside-w: 320px; --shell-gap: 24px; --shell-pad: 16px;
          --rail-pad: 8px; }
}
@media (min-width: 1512px) {
  :root { --col-w: 688px; --rail-w: 256px; --aside-w: 352px; }
}
@media (min-width: 1920px) {
  :root { --col-w: 784px; --rail-w: 288px; --aside-w: 416px;
          --shell-gap: 32px; --shell-pad: 24px; }
}

.main { min-width: 0; background: var(--bg); }
@media (min-width: 640px) {
  .main {
    border-inline: 1px solid var(--line);
    box-shadow: var(--col-shadow);
  }
}

/* ---------- left navigation rail ----------
   THE SHAPE IS `demo/social/v3/nav-menu.js` + its `.left-rail` block, rebuilt
   on this file's tokens. Three objects stacked in one column:

     .nav-menu          one glass BOX with hairline row dividers
     .btn-create-post   a full-width gradient pill, in words
     .user-profile-pill who you are, pinned to the foot

   The rail itself is now transparent — the BOX is the surface, which is the
   same answer the 2026-07-28 "bare glyphs on the page background" fix reached
   by a different route, so nothing regresses by moving the border inward.

   `--brand-gradient` is the DEMO's name for the gradient. Here it is `--brand`;
   an undefined custom property invalidates the whole declaration, which is how
   the console's selected item once ended up with no background at all while its
   `!important` still beat the rule that would have given it one. A theme lifted
   between two files carries its variable NAMES with it, and the names are the
   part that does not survive the move. */
.rail { display: none; }
@media (min-width: 640px) {
  .rail {
    display: flex; flex-direction: column; gap: var(--sp-5);
    position: sticky; top: calc(var(--appbar-h) + var(--shell-gap)); align-self: start;
    margin-block: var(--shell-gap);
    max-height: calc(100dvh - var(--appbar-h) - var(--shell-gap) * 2);
    overflow-y: auto;
  }
  .rail .nav-menu {
    display: flex; flex-direction: column; flex: 0 0 auto;
    background: var(--glass-card); backdrop-filter: blur(20px);
    border: 1px solid var(--line); border-radius: var(--r-lg);
    padding: var(--sp-2);
    /* `--card-shadow`, not a literal: the light theme replaces it with a 5%
       ink shadow, and a 50%-black drop under a white box is a bruise. */
    box-shadow: var(--card-shadow);
  }
  .rail .nav-item {
    display: flex; align-items: center; gap: var(--sp-5);
    min-height: var(--rail-item-h); justify-content: center; padding: 0; flex: 0 0 auto;
    border-radius: var(--r-md); color: var(--dim);
    font-size: var(--fs-row); font-weight: 600; position: relative;
    /* v3's row divider. `border-bottom` on the ITEM, cleared on the last one,
       so the box reads as a menu rather than as a list of floating pills. */
    border-bottom: 1px solid var(--line);
    transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
  }
  .rail .nav-item:last-of-type { border-bottom: 0; }
  .rail .nav-item svg { width: 22px; height: 22px; flex: 0 0 auto; transition: transform 0.2s ease, color 0.2s ease; }
  .rail .nav-item .icon-wrap { position: relative; display: inline-flex; flex: 0 0 auto; }
  .rail .nav-item .lbl {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
  .rail .nav-item:hover { background: var(--hover); color: var(--ink); transform: translateX(2px); }
  .rail .nav-item:hover svg { transform: scale(1.1); color: var(--ink); }
  /* `--ink`, not `#FFFFFF`. `--sel` is a 15-20% accent WASH, not a fill: in the
     light theme it lands as pale lavender, and white text on it made the ONE
     item that says where you are the only unreadable thing on the rail. --ink
     is #F8F9FC in dark, so the dark theme is unchanged. */
  .rail .nav-item[aria-current="page"] {
    background: var(--sel); color: var(--ink); font-weight: 700;
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
  }
  .rail .nav-item[aria-current="page"] svg { color: var(--a1); transform: scale(1.08); }
  .rail .dot {
    position: absolute; top: -2px; inset-inline-end: -2px;
    width: 9px; height: 9px; border-radius: var(--r-pill); background: var(--red);
    border: 2px solid var(--s1); box-shadow: 0 0 8px var(--red);
  }

  /* ── the primary action ── */
  .rail .btn-create-post {
    display: flex; align-items: center; justify-content: center; gap: var(--sp-4);
    width: 100%; height: var(--rail-item-h); flex: 0 0 auto; position: relative;
    border: 0; cursor: pointer; border-radius: var(--r-pill);
    background: var(--brand); color: var(--on-accent);
    font-size: var(--fs-row); font-weight: 700;
    box-shadow: var(--brand-glow);
    transition: all 0.2s ease;
  }
  .rail .btn-create-post svg { width: 20px; height: 20px; flex: 0 0 auto; }
  .rail .btn-create-post .lbl {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
  .rail .btn-create-post:hover {
    background: var(--brand-hover); transform: translateY(-2px);
    box-shadow: 0 8px 28px rgba(139, 61, 255, 0.5);
  }

  /* ── who you are ── */
  .rail .user-profile-pill {
    display: flex; align-items: center; gap: var(--sp-4); flex: 0 0 auto;
    margin-top: auto; padding: var(--sp-3) var(--sp-4);
    border-radius: var(--r-pill);
    background: var(--glass-card); backdrop-filter: blur(12px);
    border: 1px solid var(--line);
    transition: all 0.2s ease;
  }
  .rail .user-profile-pill:hover { background: var(--s3); border-color: var(--line-strong); }
  .rail .user-profile-pill .user-info { display: none; flex: 1; min-width: 0; }
  .rail .user-profile-pill .user-name {
    display: block; font-size: var(--fs-base); font-weight: 700; color: var(--ink);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .rail .user-profile-pill .user-handle {
    display: block; font-size: var(--fs-micro); color: var(--dim2);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
}
/* The label tier. Below this the rail is one item wide, so every label is
   CLIPPED rather than `display: none` — a screen reader still reads all of
   them, which is the rule the old icon tier already followed. */
@media (min-width: 1280px) {
  .rail .nav-item { justify-content: flex-start; padding-inline: var(--sp-5); }
  .rail .nav-item .lbl {
    position: static; width: auto; height: auto; overflow: hidden; clip-path: none;
    min-width: 0; text-overflow: ellipsis; white-space: nowrap;
  }
  .rail .btn-create-post .lbl {
    position: static; width: auto; height: auto; overflow: visible; clip-path: none;
  }
  .rail .user-profile-pill .user-info { display: block; }
}

/* The business console's own rail (`bizshell.js`) marks its selected row with
   `.active` rather than `aria-current`. Left as it was. */
.nav-item.active,
a.nav-item.active {
  background: var(--brand) !important;
  color: #FFFFFF !important;
  font-weight: 700;
  box-shadow: 0 4px 18px rgba(139, 61, 255, 0.38);
  border-color: transparent !important;
}
.nav-item.active svg { color: #FFFFFF !important; }

/* ---------- right rail ---------- */
.aside { display: none; }
@media (min-width: 1024px) {
  .aside {
    display: flex; flex-direction: column; gap: var(--shell-gap);
    position: sticky; top: calc(var(--appbar-h) + var(--shell-gap)); align-self: start;
    margin-block-start: var(--shell-gap);
    padding-block: 0 64px;
    max-height: calc(100dvh - var(--appbar-h) - var(--shell-gap) * 2); overflow-y: auto;
  }
}
.railcard {
  background: var(--glass-card-2); backdrop-filter: blur(16px);
  border: 1px solid var(--line); border-radius: var(--r-lg);
  padding: var(--sp-5); box-shadow: var(--card-shadow);
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.railcard:hover { border-color: var(--line-strong); }
.railcard h4 { margin: 0 0 var(--sp-3); font-size: var(--fs-base); font-weight: 700;
  color: var(--dim); letter-spacing: .01em; }
.railcard p { margin: 0 0 var(--sp-4); font-size: var(--fs-sm); color: var(--dim2); }
.railcard .row { background: transparent; padding: 6px 8px; width: 100%; border: 1px solid transparent; text-align: start; border-radius: var(--r-md); transition: all 0.18s ease; }
.railcard .row:hover { background: var(--hover); border-color: rgba(255, 255, 255, 0.05); transform: translateX(2px); }
.railcard .state { padding: var(--sp-6) 8px; }
.railcard .state .icon { font-size: var(--fs-h); width: auto; height: auto; background: none; border: 0; }
.railcard .list { padding: 0; gap: 2px; }
.railcard .av-wrap .online { border-color: var(--s1); box-shadow: 0 0 6px var(--green); }

/* ---------- right-rail widgets (rail.js) ----------
   `.widget` / `.widget-header` / `.trending-*` / `.follow-list` /
   `.quick-chat-row` / `.sidebar-footer` are v3's class names, so the markup in
   rail.js is v3's markup — the numbers inside it are this app's. Every colour
   here is a token: v3 was designed for dark only, and the owner reads in light.

   `.railcard` above is NOT dead — `more.js` still draws one. */
.widget {
  background: var(--glass-card-2); backdrop-filter: blur(16px);
  border: 1px solid var(--line); border-radius: var(--r-lg);
  padding: var(--sp-5); box-shadow: var(--card-shadow);
  transition: border-color 0.2s ease;
}
.widget:hover { border-color: var(--line-strong); }
.widget-header { display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-4); margin-bottom: var(--sp-4); }
.widget-header h3 {
  margin: 0; min-width: 0; display: flex; align-items: center; gap: var(--sp-3);
  font-size: var(--fs-base); font-weight: 700; color: var(--dim); letter-spacing: .01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.widget-header .wemoji { font-size: var(--fs-row); line-height: 1; flex: 0 0 auto; }
.widget-header .see-all-link {
  flex: 0 0 auto; font-size: var(--fs-sm); font-weight: 600; color: var(--a1);
}
.widget-header .see-all-link:hover { text-decoration: underline; }
.widget .wnote { margin: 0 0 var(--sp-4); font-size: var(--fs-sm); color: var(--dim2); }
.widget .state { padding: var(--sp-6) var(--sp-4); }
.widget .state .icon { font-size: var(--fs-h); width: auto; height: auto; background: none; border: 0; }
.widget .list { padding: 0; gap: 2px; }

/* เทรนด์ — three stacked lines per row, exactly v3's `.trending-item`. Each
   one is a real <a> to `#/tag/<tag>`, because a trend you cannot open is a
   headline with no article. */
.trending-list { display: flex; flex-direction: column; gap: var(--sp-1); }
.trending-item {
  display: flex; flex-direction: column; gap: 1px;
  padding: var(--sp-2) var(--sp-3); border-radius: var(--r-sm);
  transition: background 0.18s ease;
}
.trending-item:hover { background: var(--hover); }
.trend-category { font-size: 10px; color: var(--dim2); }
.trend-title { font-size: var(--fs-base); font-weight: 700; color: var(--ink);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.trend-count { font-size: var(--fs-micro); color: var(--dim2); }

/* คนที่คุณอาจรู้จัก — the card is `suggestionRow` from friends.js, unchanged.
   All this does is let its three buttons (เพิ่มเพื่อน · ไม่สนใจ · บล็อก) wrap
   under the name instead of crushing it, which is what they did at 320px. */
.follow-list { display: flex; flex-direction: column; gap: var(--sp-4); }
.follow-list .row { padding: 0; }
.follow-list .personrow { flex-wrap: wrap; }
.follow-list .personrow .rowacts { flex: 1 0 100%; display: flex; gap: var(--sp-3); flex-wrap: wrap; }

/* แชทด่วนล่าสุด — v3's horizontal face strip. */
.quick-chat-row {
  display: flex; gap: var(--sp-5); overflow-x: auto;
  padding-block: var(--sp-2); scrollbar-width: none;
}
.quick-chat-row::-webkit-scrollbar { display: none; }
.quick-chat-item {
  display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
  min-width: 52px; padding: 0; border: 0; background: none; cursor: pointer;
  transition: transform 0.18s ease;
}
.quick-chat-item:hover { transform: translateY(-2px); }
.quick-chat-item .av-wrap { position: relative; display: inline-flex; }
.quick-chat-item .qc-name {
  max-width: 52px; font-size: 10px; font-weight: 600; color: var(--dim);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.quick-chat-item .online {
  position: absolute; bottom: 0; inset-inline-end: 0;
  width: 10px; height: 10px; border-radius: var(--r-pill); background: var(--green);
  border: 2px solid var(--glass-card-2); box-shadow: 0 0 6px var(--green);
}
/* The unread count. Same red and the same "99+" ceiling as the bell, because
   they are two views of the same kind of fact. */
.quick-chat-item .badge-num {
  position: absolute; top: -3px; inset-inline-end: -5px;
  min-width: 17px; height: 17px; padding: 0 4px;
  border-radius: var(--r-pill); background: var(--red); color: #fff;
  font-size: 10px; font-weight: 800; line-height: 17px; text-align: center;
  border: 2px solid var(--glass-card-2);
}

/* ลิงก์ท้าย. Only pages that exist — see rail.js on the missing cookie page. */
.sidebar-footer {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-4) var(--sp-6);
  font-size: var(--fs-micro); color: var(--dim2); line-height: 1.6;
}
.sidebar-footer a:hover { color: var(--ink); text-decoration: underline; }
.sidebar-footer .copy { flex: 1 0 100%; }

/* ---------- per-screen column header ---------- */
.colhead {
  position: sticky; top: var(--appbar-h); z-index: 25;
  display: flex; align-items: center; gap: var(--sp-2); min-height: var(--colhead-h);
  padding-inline: var(--sp-4); padding-block: 0;
  background: var(--glass-header);
  backdrop-filter: blur(18px) saturate(180%);
  border-bottom: 1px solid var(--line);
}
.colhead .title {
  flex: 1; min-width: 0; margin: 0;
  padding-inline-start: var(--sp-4);
  font-size: var(--fs-title); font-weight: 700; letter-spacing: -.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.colhead:has([data-back]:not([hidden])) .title { padding-inline-start: 0; }
.colhead .sub { display: block; font-size: var(--fs-micro); font-weight: 400; color: var(--dim);
  letter-spacing: 0; }
.colhead[data-plain="1"] { display: none; }
.colhead [data-colacts] { display: contents; }
@media (min-width: 640px) { .colhead [data-settings] { display: none; } }
@media (min-width: 640px) { .colhead[data-plain="1"] { display: flex; } }

.iconbtn {
  display: inline-grid; place-items: center;
  width: 40px; height: 40px; flex: 0 0 auto;
  border: 0; border-radius: var(--r-md); background: transparent; color: var(--dim);
  cursor: pointer; position: relative; transition: all 0.18s ease;
}
@media (pointer: coarse) {
  .iconbtn { width: 44px; height: 44px; }
}
.iconbtn:hover { background: var(--hover); color: var(--ink); transform: scale(1.05); }
.iconbtn svg { width: 21px; height: 21px; }
.iconbtn .count {
  position: absolute; top: 1px; inset-inline-end: 1px; min-width: 17px; height: 17px; padding: 0 4px;
  border-radius: var(--r-pill); background: var(--red); color: #fff;
  font-size: var(--fs-micro); font-weight: 800; line-height: 17px; text-align: center;
  border: 2px solid var(--bg); box-shadow: 0 0 8px var(--red);
}
/* "Failed to load", never the same colour as "zero" — same rule the services
   menu already applies to its own count. Amber is the one status colour that
   is allowed to mean something other than its usual meaning here, because
   this is the one spot CLAUDE.md records the exception for. */
.iconbtn .count.unknown { background: var(--amber); }

.avbtn { border: 0; background: transparent; padding: 3px; border-radius: var(--r-pill); cursor: pointer; line-height: 0; }
.avbtn:hover { background: var(--hover); }

/* ชิดล่าง. The >=1024 rule reserved 72px at the foot of EVERY screen for the
   chat dock — but the dock is pinned to `inset-inline-end: 20px` and is 232px
   wide, so at every width where it exists it overlaps the RIGHT RAIL and never
   the content column (at 1024: dock spans 772-1004, the column ends around
   700). Seventy-two pixels of nothing under every page, to clear something
   that was never over it. The reservation moves to the rail, which is the
   thing actually underneath it. */
.view { flex: 1; width: 100%; padding-bottom: calc(var(--tabbar-h) + var(--sp-7)); }
@media (min-width: 640px) { .view { padding-bottom: var(--sp-7); } }

/* ---------- bottom tab bar (mobile floating glass) ---------- */
.tabbar {
  position: fixed; inset-inline: 0; bottom: 0; z-index: 40;
  display: flex; justify-content: space-around; align-items: stretch;
  background: var(--glass-header);
  backdrop-filter: blur(20px) saturate(180%);
  border-top: 1px solid var(--line);
  padding-bottom: env(safe-area-inset-bottom, 0);
  /* Upward, and tinted with the theme's own ink: a 50%-black band over a light
     page reads as a smudge, not as a lifted bar. */
  box-shadow: 0 -10px 30px color-mix(in srgb, var(--bg) 55%, transparent);
}
.tabbar a {
  flex: 1; min-width: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 3px; padding: 8px 0 6px; color: var(--dim2); font-size: var(--fs-micro); font-weight: 500;
  position: relative; transition: all 0.18s ease;
}
.tabbar a svg { width: 22px; height: 22px; transition: transform 0.18s ease, color 0.18s ease; }
.tabbar a:hover { color: var(--ink); }
/* `--ink`: the bar is glass over the PAGE, not a filled accent, so in the light
   theme white-on-white erased the label of the tab you are standing on — on the
   phone, which is where the tab bar is the only navigation there is. */
.tabbar a[aria-current="page"] { color: var(--ink); font-weight: 700; }
.tabbar a[aria-current="page"] svg { color: var(--a1); transform: scale(1.12); filter: drop-shadow(0 2px 8px rgba(255, 59, 136, 0.5)); }
.tabbar a span:not(.dot) { max-width: calc(100% - 8px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tabbar .dot {
  position: absolute; top: 6px; inset-inline-start: 50%; margin-inline-start: var(--sp-4);
  width: 8px; height: 8px; border-radius: var(--r-pill); background: var(--red);
  border: 2px solid var(--bg); box-shadow: 0 0 8px var(--red);
}
@media (min-width: 640px) {
  :root { --tabbar-h: 0px; }
  .tabbar { display: none; }
}

/* ---------- floating "เขียนโพสต์" (main.js `.fabcompose`) ----------
   The rail's ✏ button is the desktop entry point and the rail does not exist
   below 640px, so this is the ONLY compose button a phone has. Same
   `data-compose` hook, same handler. Above the tab bar and the home indicator,
   and above the install bar when that is up (`body[data-installbar]`, the same
   token `.view`'s padding already uses). It stands down on the routes whose
   bottom edge is already taken: the chat room's message bar and the comment
   composer on a post. */
.fabcompose {
  position: fixed; z-index: 45;
  inset-inline-end: 16px;
  bottom: calc(var(--tabbar-h) + 14px + env(safe-area-inset-bottom, 0px));
  width: 54px; height: 54px; padding: 0; border: 0; cursor: pointer;
  border-radius: var(--r-pill);
  display: grid; place-items: center;
  background: var(--brand); color: var(--on-accent); box-shadow: var(--brand-glow);
  transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.fabcompose svg { width: 24px; height: 24px; }
.fabcompose:hover { transform: translateY(-1px) scale(1.03); }
.fabcompose:active { transform: scale(0.97); }
body[data-installbar="1"] .fabcompose { bottom: calc(var(--tabbar-h) + 78px + env(safe-area-inset-bottom, 0px)); }
.shell[data-route="chat"] .fabcompose,
.shell[data-route="mchat"] .fabcompose,
.shell[data-route="post"] .fabcompose { display: none; }
/* From 640px up the rail is back and it carries the button — two compose
   buttons on one screen is one too many. */
@media (min-width: 640px) { .fabcompose { display: none; } }

/* ==========================================================================
   Lists — the density table

   The gaps used to step UP from 640px (6->8 row, 8->10 card, 8->10 list pad)
   on the theory that a desktop wants more air. That is exactly the thing the
   owner called เทอะทะ: lists are the content and padding is not, and the ratio
   of content to padding must not get worse the moment the window gets bigger.
   The house numbers in CLAUDE.md hold at EVERY width now. Only the row's own
   inner padding steps up (7/10 -> 8/12), because a row that has to be hit with
   a fingertip and a row that is hit with a cursor genuinely differ.
   ========================================================================== */
.list { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); }
.list.cards { gap: var(--sp-4); }

.row {
  display: flex; align-items: center; gap: var(--sp-4);
  padding: 7px 10px;                        /* CLAUDE.md house row density */
  border-radius: var(--r-md);
  background: var(--s2);
  /* `.row` is an <a> on most screens but a <button> in the rail and the dock,
     and a button inherits `text-align: center` from the UA sheet. Both of those
     patched it locally; stating it on the base class means the next screen that
     needs a button row does not have to rediscover why its list is centred. */
  text-align: start;
  border: 1px solid transparent;
  cursor: pointer;
  /* No ripple / flash on a large surface — only a quiet border change. */
  transition: border-color .12s ease, background-color .12s ease;
}
.row:hover { border-color: var(--line); }
.row .grow { flex: 1; min-width: 0; }
.row .l1 { font-size: var(--fs-row); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .l2 { font-size: var(--fs-sm); color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .meta { font-size: var(--fs-micro); color: var(--dim2); flex: 0 0 auto; text-align: end; max-width: 46%; }

/* A section label is a different KIND of thing from a row, so it needs to read
   as one. What it must NOT use is `text-transform: uppercase` — the obvious
   move, and wrong here: Thai and Arabic have no letter case, so it would build
   the hierarchy for the Latin languages and silently drop it for the readers
   the app is actually for. Size + weight + the dim rung work in every script.
   The house value (13px bold NovaDim) with real space above it. */
.section-label {
  font-size: var(--fs-base); font-weight: 700; color: var(--dim);
  letter-spacing: .01em;
  padding: var(--sp-5) 0 var(--sp-1); padding-inline: var(--sp-6); text-align: start;
}

@media (min-width: 640px) {
  .row { padding: var(--sp-4) var(--sp-5); }
}

/* ---------- avatar ---------- */
.av {
  flex: 0 0 auto; width: 40px; height: 40px; border-radius: var(--r-pill);
  display: grid; place-items: center; overflow: hidden;
  background: var(--brand); color: #fff; font-weight: 700; font-size: var(--fs-body);
  user-select: none;
}
/* Four sizes, not five: `.xs` (30) and `.tiny` (28) were two names for the same
   idea two pixels apart, which is how a page ends up with avatars that almost
   line up. 40 row · 32 dense · 28 inline · 96 profile. */
.av img { width: 100%; height: 100%; object-fit: cover; }
.av.sm { width: 32px; height: 32px; font-size: var(--fs-base); }
.av.xs { width: 28px; height: 28px; font-size: var(--fs-sm); }
.av.tiny { width: 28px; height: 28px; font-size: var(--fs-sm); }
.av.xl { width: 96px; height: 96px; font-size: 32px; }
.av-wrap { position: relative; flex: 0 0 auto; }
.av-wrap .online {
  position: absolute; inset-inline-end: 0; bottom: 0; width: 11px; height: 11px; border-radius: var(--r-pill);
  background: var(--green); border: 2px solid var(--s2);
}

/* ==========================================================================
   Buttons / fields / chips
   ========================================================================== */
/* 9px vertical. At 12px a `.btn` drew 45px tall — half again the 30px `.btn.sm`
   that sits in every list row, and taller than any field on the page, so the
   Save button on the profile form read as the largest object on the screen.
   CLAUDE.md puts a button inside a row at `vertical = 6.dp` (that is `.btn.sm`);
   a standalone primary action is the next rung up, not three rungs. 9px gives
   38px, between the row button and the 40px field. The radius is untouched at
   22px — already inside CLAUDE.md's 20–24 band. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  padding: 9px var(--sp-6); border-radius: var(--r-btn); border: 0; cursor: pointer;
  background: var(--brand); color: var(--on-accent);
  font-size: var(--fs-row); font-weight: 600; line-height: 1.2;
  max-width: 100%; min-width: 0;
  box-shadow: var(--brand-glow);
  transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.btn:hover { background: var(--brand-hover); transform: translateY(-1px); box-shadow: 0 6px 24px rgba(139, 61, 255, 0.45); }
.btn:active { transform: translateY(0); box-shadow: 0 2px 10px rgba(139, 61, 255, 0.3); }
.btn:disabled { opacity: .5; cursor: not-allowed; filter: none; transform: none; box-shadow: none; }
.btn.ghost { background: transparent; border: 1px solid var(--line-strong); color: var(--ink); box-shadow: none; }
.btn.ghost:hover { border-color: var(--a2); background: var(--hover); box-shadow: 0 0 12px rgba(139, 61, 255, 0.2); }
.btn.quiet { background: var(--s3); color: var(--ink); box-shadow: none; }
.btn.quiet:hover { background: var(--line); }
.btn.sm { padding: 6px var(--sp-5); font-size: var(--fs-base); }
.btn.danger { background: transparent; color: var(--red); border: 1px solid transparent; box-shadow: none; }
.btn.danger:hover { border-color: rgba(239, 68, 68, 0.4); background: rgba(239, 68, 68, 0.1); }
.btn.block { width: 100%; }
.btn svg { width: 17px; height: 17px; flex: 0 0 auto; }

.field {
  width: 100%; padding: var(--sp-4) var(--sp-5); border-radius: var(--r-md);
  background: var(--glass-input); border: 1px solid var(--line); color: var(--ink);
  outline: none; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.field:hover { border-color: rgba(139, 61, 255, 0.4); background: var(--glass-input-hover); }
.field:focus-visible { border-color: var(--a2); background: var(--glass-input-focus); outline: none; box-shadow: 0 0 16px rgba(139, 61, 255, 0.3), 0 0 0 2px var(--a2); }
.field::placeholder { color: var(--dim2); }
.field:-webkit-autofill,
.field:-webkit-autofill:hover,
.field:-webkit-autofill:focus {
  -webkit-text-fill-color: var(--ink);
  -webkit-box-shadow: 0 0 0 1000px var(--s2) inset;
  box-shadow: 0 0 0 1000px var(--s2) inset;
  caret-color: var(--ink);
  transition: background-color 999999s ease-in-out 0s;
}
textarea.field { resize: vertical; min-height: 84px; line-height: 1.5; }
.label { display: block; font-size: var(--fs-sm); font-weight: 600; color: var(--dim);
  margin: 0 0 5px; margin-inline-start: 2px; text-align: start; }

.chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px var(--sp-4); border-radius: var(--r-sm); font-size: var(--fs-sm); font-weight: 600;
  background: var(--s3); color: var(--dim); border: 1px solid transparent;
  transition: all 0.18s ease;
}
.chip.amber  { background: rgba(245, 158, 11, 0.16); color: var(--amber); border-color: rgba(245, 158, 11, 0.3); }
.chip.blue   { background: rgba(59, 130, 246, 0.16);  color: var(--blue);  border-color: rgba(59, 130, 246, 0.3); }
.chip.purple { background: rgba(139, 92, 246, 0.16); color: var(--purple); border-color: rgba(139, 92, 246, 0.3); }
.chip.sky    { background: rgba(14, 165, 233, 0.16); color: var(--sky); border-color: rgba(14, 165, 233, 0.3); }
.chip.green  { background: rgba(34, 197, 94, 0.16); color: var(--green); border-color: rgba(34, 197, 94, 0.3); }
.chip.red    { background: rgba(239, 68, 68, 0.16);   color: var(--red);   border-color: rgba(239, 68, 68, 0.3); }

select.chip {
  appearance: none; -webkit-appearance: none;
  border: 1px solid var(--line); cursor: pointer; color: var(--dim);
  padding-inline: var(--sp-4) var(--sp-7);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236E6A8A' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 8px center; background-size: 13px;
}
select.chip:hover { border-color: rgba(139, 61, 255, 0.4); color: var(--ink); }
html[dir="rtl"] select.chip { padding-inline: var(--sp-7) var(--sp-4); background-position: left 8px center; }

/* ---------- segmented tabs ---------- */
.segs { display: flex; flex-wrap: wrap; gap: 0; row-gap: 3px; padding: 4px; margin: 8px 16px 0;
  background: var(--glass-seg); border: 1px solid var(--line); border-radius: var(--r-md); }
.segs button {
  flex: 1; min-width: 0; padding: var(--sp-4) var(--sp-4); border: 0; border-radius: var(--r-sm); cursor: pointer;
  background: transparent; color: var(--dim); font-size: var(--fs-base); font-weight: 600; line-height: 1.25;
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  transition: all 0.18s ease;
}
.segs:not(.col) button { min-width: fit-content; white-space: nowrap; }
.segs button:hover { color: var(--ink); background: var(--hover); }
.segs button[aria-selected="true"] { background: var(--brand); color: #fff; box-shadow: var(--brand-glow); }
.segs .needs { width: 7px; height: 7px; border-radius: var(--r-pill); background: var(--red); flex: 0 0 auto; }

.segs.many { gap: 5px; row-gap: 5px; padding: 5px; justify-content: flex-start; }
.segs.many button {
  flex: 0 0 auto; border-radius: var(--r-pill); padding: var(--sp-3) var(--sp-5);
  background: var(--s3); border: 1px solid var(--line);
}
.segs.many button:hover { border-color: var(--a2); }
.segs.many button[aria-selected="true"] { background: var(--brand); border-color: transparent; color: #fff; box-shadow: var(--brand-glow); }

/* ==========================================================================
   State box
   ========================================================================== */
.state {
  display: grid; place-items: center; gap: 8px; text-align: center;
  padding: 40px 24px; color: var(--dim);
}
.state .icon { font-size: 24px; line-height: 1; }
.state .headline { font-size: var(--fs-row); font-weight: 600; color: var(--ink); }
.state .detail { font-size: var(--fs-base); color: var(--dim2); max-width: 34ch; word-break: break-word; }
.state.failed .headline { color: var(--red); }
@media (min-width: 640px) {
  .state { padding: 60px 24px; gap: var(--sp-4); }
  .state .icon {
    font-size: var(--fs-hero); width: 68px; height: 68px; border-radius: var(--r-pill);
    display: grid; place-items: center; background: var(--brand-soft);
    border: 1px solid var(--line); box-shadow: 0 0 20px rgba(139, 61, 255, 0.2);
  }
  .state .headline { font-size: var(--fs-title); }
  .state .detail { font-size: var(--fs-base); }
}
.spinner {
  width: 22px; height: 22px; border-radius: var(--r-pill);
  border: 2.5px solid var(--line); border-top-color: var(--a2);
  animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2s; } }

.sk { border-radius: var(--r-md); background: var(--s3); position: relative; overflow: hidden; }
.sk::after {
  content: ""; position: absolute; inset: 0; transform: translateX(-100%);
  background: linear-gradient(90deg, transparent,
    rgba(255, 255, 255, 0.08), transparent);
  animation: shimmer 1.3s infinite;
}
@keyframes shimmer { 100% { transform: translateX(100%); } }
@media (prefers-reduced-motion: reduce) { .sk::after { animation: none; } }
.sk.circle { border-radius: var(--r-pill); }
.sk-row { display: flex; align-items: center; gap: var(--sp-4); padding: 7px 10px;
  border-radius: var(--r-md); background: var(--s2); }
.sk-card { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg);
  padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-4); box-shadow: var(--card-shadow); }
.sk-card .top { display: flex; align-items: center; gap: var(--sp-4); }
.sk-lines { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
@media (min-width: 640px) {
  .sk-row { padding: var(--sp-4) 12px; }
  .sk-card { padding: var(--sp-5); }
}

.banner {
  display: flex; align-items: center; gap: var(--sp-4);
  margin: var(--sp-4) var(--sp-6) 0; padding: var(--sp-4) var(--sp-5); border-radius: var(--r-md);
  font-size: var(--fs-sm);
  background: rgba(245, 158, 11, 0.14);
  color: var(--amber);
  border: 1px solid rgba(245, 158, 11, 0.3);
}
.banner .grow { flex: 1; min-width: 0; }
.banner.bad {
  background: rgba(239, 68, 68, 0.14);
  color: var(--red);
  border-color: rgba(239, 68, 68, 0.3);
}
.banner.stack { display: block; }
.banner.stack > * + * { margin-top: 2px; }
.banner .dim { color: inherit; opacity: .82; }

/* ==========================================================================
   Feed
   ========================================================================== */
.post, .sk-post { --post-pad: var(--sp-5); }
@media (min-width: 640px) { .post, .sk-post { --post-pad: var(--sp-6); } }
/* CONTINUOUS FEED (v3, owner's call 2026-08-14). v3's stream is
   `.posts-stream { display:flex; flex-direction:column }` with
   `.post-card { padding:16px; border-bottom:1px solid var(--border) }` — no gap,
   no side gutter, no four-sided frame, no radius, no shadow. Cards meet the
   column edge and ONE hairline divides them.

   Scoped by CONTENT, not by screen: `.list.cards` is shared with shops, orders,
   automation rows and the Korat-ID sheet, so removing the gutter on `.list`
   itself would strip padding off half the app. `:has()` limits it to the lists
   that actually hold posts — feed, single post, profile, search, tag, group —
   which is the same object everywhere and must not differ by route.
   The descendant form catches `feedSkeleton()`, which nests a second
   `.list.cards` of `.sk-post` inside the real one. */
.list.cards:has(> .post),
.list.cards:has(.sk-post) { gap: 0; padding-inline: 0; }
/* Objects in the stream that are NOT posts keep their inset: the shelf is a
   floating card between two full-bleed ones, and a state box that lands beside
   posts must not run its dashed border into the screen edge. (An EMPTY feed has
   no `.post`, so the list keeps `.list`'s own gutter and this never doubles.) */
.list.cards:has(> .post) > .suggest-rail { margin: var(--sp-5) var(--sp-6); }
.list.cards:has(> .post) > .state { margin-inline: var(--sp-6); }
/* The feed's own empty / failed box occupies a post's slot in the stream, so it
   takes the post's shape. Left rounded it would be the one curved object in a
   column of square ones — and it appears exactly when the reader is already
   looking for something wrong. */
.feed .list.cards > .state {
  padding: var(--sp-8) var(--sp-7); border-radius: 0;
  background: var(--s1); border: 1px dashed var(--line);
}
.feed .list.cards > .state.failed {
  border-style: solid; border-color: rgba(239, 68, 68, 0.34);
}

.composer { margin: var(--sp-4) var(--sp-6) 0; padding: var(--sp-4); border-radius: var(--r-lg);
  background: var(--glass-card); backdrop-filter: blur(16px);
  border: 1px solid var(--line); box-shadow: var(--card-shadow);
  transition: border-color 0.2s ease, box-shadow 0.2s ease; }
.composer:hover { border-color: var(--line-strong); }
.composer .top { display: flex; gap: var(--sp-4); align-items: flex-start; }
.composer textarea {
  flex: 1; min-height: 40px; outline: none; resize: none;
  padding: 9px var(--sp-5); font-size: var(--fs-body); line-height: 1.5;
  background: var(--glass-input); border: 1px solid var(--line); border-radius: var(--r-md);
  color: var(--ink); transition: all 0.2s ease;
}
.composer textarea::placeholder { color: var(--dim2); }
.composer textarea:hover { border-color: rgba(139, 61, 255, 0.4); background: var(--glass-input-hover); }
.composer textarea:hover { border-color: color-mix(in srgb, var(--a2) 30%, var(--line)); }
/* ONE ring, not two. The focus ring is the `outline` (it is the one a keyboard
   user must see, and it must survive forced-colours); the border therefore stays
   the neutral hairline on focus instead of also turning purple 2px inside it,
   which is what read as a thick double stroke. The glow stays — it is light, not
   a second edge. */
.composer textarea:focus-visible {
  border-color: var(--line);
  background: var(--glass-input-focus);
  box-shadow: 0 0 16px rgba(139, 61, 255, 0.25);
  outline: 2px solid var(--a2); outline-offset: 2px;
}
/* The tool row is a different KIND of thing from the box above it: a hairline
   says so, instead of four controls floating under a field with nothing
   grouping them. */
.composer .bar {
  display: flex; align-items: center; gap: var(--sp-4);
  margin-top: var(--sp-4); padding-top: var(--sp-4);
  border-top: 1px solid var(--line);
}
/* completed to match `.u-grow`: `flex: 1` alone still carries
   `min-width: auto`, so this column grew but refused to shrink. */
.composer .bar .grow { flex: 1; min-width: 0; }
.composer .previews { display: flex; gap: var(--sp-3); flex-wrap: wrap; margin-top: var(--sp-4); }
.composer .previews figure { position: relative; margin: 0; width: 64px; height: 64px; border-radius: var(--r-md); overflow: hidden; }
.composer .previews img { width: 100%; height: 100%; object-fit: cover; }
.composer .previews button {
  position: absolute; top: 3px; inset-inline-end: 3px; width: 20px; height: 20px; border: 0; cursor: pointer;
  border-radius: var(--r-pill); background: rgba(0,0,0,.62); color: #fff; font-size: var(--fs-sm); line-height: 20px; padding: 0;
}
@media (min-width: 640px) { .composer { margin: var(--sp-5) var(--sp-6) 0; padding: var(--sp-5); } }

/* --------------------------------------------------------------------------
   THE POST CARD — the object this whole app is most often looked at through.

   THE ONE STRUCTURAL DECISION: the card has NO padding of its own. Every text
   element carries `--post-pad` on its inline edges and the PHOTOS carry none,
   so media runs from edge to edge of the card and is clipped by the card's own
   radius. Before this the gutter was on the card, so every photo was inset by
   8px of surface on all four sides — a white frame around the one thing the
   reader came for, and 16px of the column's width spent on nothing. Content is
   the card; the chrome gets out of its way.
   -------------------------------------------------------------------------- */
.post {
  background: var(--s1); border-radius: 0; padding: 0;
  /* NO BORDER, NO SHADOW: the divider is `::after` (see the winning block
     below). A border here would also shrink the padding box by 1px, and the
     neon frame is `inset: 0` on that box — the frame would then sit a pixel
     inside the card's own edge. */
  border: 0; box-shadow: none;
  overflow: hidden;
  transition: background-color .12s ease;
}
/* The text side of the card, stated once. Anything NOT in this list (the photo
   grid) is deliberately full-bleed. */
.post > .head, .post > .body, .post > .acts, .post > .serieschip { padding-inline: var(--post-pad); }
.post > .head { display: flex; align-items: center; gap: var(--sp-4); padding-top: var(--post-pad); }
.post > .serieschip { margin-top: var(--post-pad); }
.post.ep > .head { padding-top: var(--sp-4); }
/* Only the avatar and the name open the profile. Everything else opens the post. */
.post .who { display: flex; align-items: center; gap: var(--sp-4); cursor: pointer; border-radius: var(--r-md); min-width: 0; }
.post .who > div { min-width: 0; }
.post .who:hover .name { text-decoration: underline; }
/* 700, not 600: the author is the second-loudest thing on the card after the
   photo, and at 14px the gap between 600 and the 500 of an ordinary row was
   not a level anybody could see. */
.post {
  background: var(--glass-card); backdrop-filter: blur(16px);
  /* SQUARE + FRAMELESS. This is the block that actually wins (it restates
     `.post` later in the file than the one above), so every one of these has to
     be stated in BOTH or the card silently keeps the old value. `--r-lg` and
     `--card-shadow` themselves are untouched: every other card still rounds. */
  border-radius: 0; padding: 0;
  border: 0;
  box-shadow: none;
  overflow: hidden;
  /* `position: relative` is now structural, not decorative: the divider
     (`::after`), the episode bar (`::before`) and the neon layer all hang off
     it. It used to be declared only under `.post.neon`. */
  position: relative;
  transition: background-color 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
/* THE DIVIDER. Not `border-bottom`, on purpose: a border eats 1px off the
   padding box, and `.neonfx { inset: 0 }` is measured on that box — a bordered
   card would hang the paid frame's bottom edge a pixel above the card's. Drawn
   instead as a hairline inside the box, which no other layer has to know about. */
.post::after {
  content: ""; position: absolute; inset-inline: 0; bottom: 0; height: 1px;
  background: var(--line); pointer-events: none;
  transition: background-color .12s ease;
}
/* Nothing follows the last card, so a line there is a stroke hanging in space —
   v3 gets away with it because its stream ends against a footer; this one ends
   against the loader and the bottom bar. */
.list.cards > .post:last-child::after,
.post:only-child::after { display: none; }
/* HOVER. v3 answers with `background-color` alone (`.post-card:hover {
   background: var(--bg-card-hover) }`) because a frameless card has no frame to
   light up — and `transform: translateY` on an edge-to-edge card in a gapless
   column shears the divider against its neighbour. `--hover` is the app's own
   overlay token and reads in both themes (white 6% dark / ink 7% light); it is
   applied as a background IMAGE so it layers over the glass colour instead of
   replacing it. The divider darkens with it, so the row being pointed at is
   bounded as well as lit. */
.post:hover { background-image: linear-gradient(var(--hover), var(--hover)); }
.post:hover::after { background: var(--line-strong); }
.post > .head, .post > .body, .post > .acts, .post > .serieschip { padding-inline: var(--post-pad); }
.post > .head { display: flex; align-items: center; gap: var(--sp-4); padding-top: var(--post-pad); }
.post > .serieschip { margin-top: var(--post-pad); }
.post.ep > .head { padding-top: var(--sp-4); }
.post .who { display: flex; align-items: center; gap: var(--sp-4); cursor: pointer; border-radius: var(--r-md); min-width: 0; }
.post .who > div { min-width: 0; }
.post .who:hover .name { text-decoration: underline; color: var(--a1); }
.post .who .name { font-size: var(--fs-row); font-weight: 700; letter-spacing: -.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color 0.18s ease; }
.post .when { font-size: var(--fs-micro); color: var(--dim2); line-height: 1.35; }
.post .openarea { flex: 1; min-width: 0; align-self: stretch; cursor: pointer; }
.post .body { margin: var(--sp-4) 0 0; font-size: var(--fs-body);
  white-space: pre-wrap; word-break: break-word; cursor: pointer; }
.post.text-only .body { font-size: var(--fs-title); line-height: 1.55; }
.post.text-only.brief .body {
  font-size: var(--fs-h); line-height: 1.5; font-weight: 600; letter-spacing: -.01em;
  padding-block: var(--sp-3) var(--sp-2);
}
.post .shots { display: grid; gap: 2px; margin-top: var(--sp-4); overflow: hidden; cursor: pointer; }
.post .shots.n1 { grid-template-columns: 1fr; }
.post .shots.n2 { grid-template-columns: 1fr 1fr; aspect-ratio: 2 / 1; }
.post .shots.n3 { grid-template-columns: 2fr 1fr; grid-template-rows: 1fr 1fr; aspect-ratio: 3 / 2; }
.post .shots.n3 > img:first-child { grid-row: 1 / span 2; }
.post .shots.n4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; aspect-ratio: 4 / 3; }
.post .shots.n2, .post .shots.n3, .post .shots.n4 { max-height: min(420px, 52vh); }
@media (min-width: 640px) { .post .shots.n2, .post .shots.n3, .post .shots.n4 { max-height: min(520px, 62vh); } }
.post .shots.n2 img, .post .shots.n3 img, .post .shots.n4 img {
  width: 100%; height: 100%; object-fit: cover; background: var(--s3);
  transition: filter 0.2s ease, transform 0.3s ease;
}
.post .shots img:hover { filter: brightness(1.05); transform: scale(1.01); }
.post .shots.n1 img {
  width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: contain; background: var(--s3); display: block;
}
.post .shots.n1 img.tall { object-fit: cover; }

.post .acts {
  display: flex; align-items: center; gap: var(--sp-1);
  margin-top: var(--sp-4); padding-block: var(--sp-2);
  padding-inline: calc(var(--post-pad) - var(--sp-4));
  border-top: 1px solid var(--line);
}
.post:has(.shots) .acts { margin-top: 0; }
.post .acts .grow { flex: 1; min-width: 0; }
.post .act {
  display: inline-flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4);
  border: 0; border-radius: var(--r-pill); background: transparent; color: var(--dim);
  font-size: var(--fs-base); font-weight: 600; cursor: pointer;
  font-variant-numeric: tabular-nums;
  transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
}
.post .act:hover { background: var(--hover); color: var(--ink); transform: scale(1.04); }
.post .act.on { color: #FFF; background: var(--brand); box-shadow: var(--brand-glow); }
.post .act.on:hover { background: var(--brand-hover); color: #FFF; transform: scale(1.06); }
.post .act.sm { padding: var(--sp-3); color: var(--dim2); }
.post .act.sm:hover { color: var(--ink); }
.post .act.warn:hover { color: var(--red); background: rgba(239, 68, 68, 0.14); }
.post .act svg { width: 18px; height: 18px; }
.post .vis { font-size: var(--fs-micro); color: var(--dim2); }
@media (pointer: coarse) { .post .act { padding-block: 10px; } }

/* The skeleton is the SAME BOX as `.post` and must be squared in the same edit:
   a rounded skeleton that resolves into a square card is a visible pop on every
   single feed load, and it is the half nobody comes back to. */
.sk-post {
  background: var(--glass-card); backdrop-filter: blur(16px);
  border: 0; border-radius: 0;
  box-shadow: none; overflow: hidden; position: relative;
}
/* Same divider, same rule about the last one: a skeleton column of floating
   boxes that resolves into a continuous stream is a visible jump on every load. */
.sk-post::after {
  content: ""; position: absolute; inset-inline: 0; bottom: 0; height: 1px;
  background: var(--line); pointer-events: none;
}
.list.cards > .sk-post:last-child::after { display: none; }
.sk-post .head { display: flex; align-items: center; gap: var(--sp-4); padding: var(--post-pad) var(--post-pad) 0; }
.sk-post .lines { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.sk-post .txt { display: flex; flex-direction: column; gap: 7px; padding: var(--sp-4) var(--post-pad) 0; }
.sk-post .media { margin-top: var(--sp-4); border-radius: 0; aspect-ratio: 16 / 10; }
.sk-post .foot {
  display: flex; gap: var(--sp-6); margin-top: var(--sp-4);
  padding: var(--sp-4) var(--post-pad); border-top: 1px solid var(--line);
}

/* ==========================================================================
   คนที่คุณอาจรู้จัก — the suggestion shelf inside the feed
   ==========================================================================
   THESE RULES USED TO LIVE IN `feed.js`, half of them as inline `style`
   attributes and half injected from a template literal into <head>, because
   this stylesheet was being rebuilt by another agent at the time and two
   writers on one file is a change that vanishes with no error. That reason is
   gone; carrying a second stylesheet inside a module is not something to keep
   for its own sake — an inline `style` beats every rule here at any
   specificity, so a breakpoint could never reach the cards at all.

   IT IS NOT A CARD, AND THAT IS THE POINT. Posts are `--s1` with a hairline;
   this is `--s3` with none. A feed where every object has the same border is a
   list of panels, so the one thing in the stream that is NOT a post says so
   with its surface instead of with a label. `--s3` is a real step in both
   themes (light: #EBE9F5 against white cards; dark: #1F1F36 against #12121F),
   which a border-swap alone would not be.

   `--sug-w` is the card width and it lives on the section, so a breakpoint has
   one declaration to change:
     phone   46% of the column — two whole cards plus a peek, which is the only
             honest signal that a row scrolls by hand
     >=640   exactly three, no sliver: once the ‹ › pair exists the peek is not
             an affordance any more, it is a cut-off card
     >=1512  exactly four, because the column is 688px there and three cards
             leave a card-shaped hole in a row that is meant to look full. */
.suggest-rail {
  --sug-w: clamp(150px, 46%, 176px);
  background: var(--s3); border-radius: var(--r-lg);
  padding: var(--sp-2) var(--sp-5) var(--sp-4);
}
.suggest-rail .section-label {
  padding: var(--sp-4) 0 var(--sp-3);
  display: flex; align-items: center; gap: var(--sp-4);
}
.suggest-rail .section-label > .ttl { flex: 1; min-width: 0; }
.suggest-track {
  display: flex; gap: var(--sp-3); overflow-x: auto;
  scroll-snap-type: x proximity; scroll-behavior: smooth;
  /* The shelf scrolls sideways; the page must never — and this also stops a
     swipe past the end turning into the browser's back gesture. */
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  /* Room for the focus ring of a card's button, which is drawn outside the box
     and would otherwise be clipped by the scroller — and the snap positions
     have to know about that padding, or the track rests 3px scrolled on first
     paint and the first card sits 3px inside the column above it. */
  padding: 3px 3px 6px; margin: 0 -3px; scroll-padding-inline: 3px;
}
.suggest-track::-webkit-scrollbar { display: none; }
@media (prefers-reduced-motion: reduce) { .suggest-track { scroll-behavior: auto; } }
/* `min-width: 0` is what makes the width above mean anything: a flex item's
   default `min-width: auto` floors it at its own min-content width — measured
   253px, set by the longest Thai name — so every card came out the same size at
   every viewport and the carousel silently stopped being one. */
.suggest-rail .scard {
  flex: 0 0 var(--sug-w); scroll-snap-align: start;
  align-items: flex-start; gap: var(--sp-3); min-width: 0;
  position: relative; cursor: default;
  background: var(--s1); border: 1px solid var(--line);
}
.suggest-rail .scard:hover { border-color: color-mix(in srgb, var(--a2) 34%, var(--line)); }
/* A rounded SQUARE, not the circle `.av` draws everywhere else — the app-icon
   shape the owner asked for. It stays 40px: every pixel it gains comes out of
   the name's width, which is the one measurement in this card that cannot give. */
.suggest-rail .scard .av { border-radius: var(--r-md); }
.suggest-rail .scard .who { cursor: pointer; padding-inline-end: 18px; }
.suggest-rail .scard .who:hover { text-decoration: underline; }
/* The reason gets TWO lines rather than one — at ~150px a single 12px line cuts
   "มีเพื่อนร่วมกัน 3 คน" mid-word — and it clamps rather than ellipsising at the
   character, with the full sentence in the title. 1.45 because Thai stacks
   vowels above and tone marks above those; 1.2 clips them. */
.suggest-rail .scard .l2 {
  white-space: normal; line-height: 1.45;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.suggest-rail .scard .col {
  flex: 1; min-width: 0; display: flex; flex-direction: column;
  gap: var(--sp-2); align-self: stretch;
}
.suggest-rail [data-skip] {
  position: absolute; inset-inline-end: 3px; top: 1px;
  line-height: 1; font-size: var(--fs-sm); padding: 2px 4px;
}
/* THE ADD-FRIEND BADGE, on the top-inline-end corner of the photo. It draws at
   20px and its BUTTON is 40px and transparent: a 20px control is under every
   touch guideline there is (WCAG 2.5.8 asks 24), so the visual shrinks and the
   target does not follow it down.
   It can never read as a count badge: a count is red, carries digits and hangs
   off an icon. This is accent (an action of mine) / amber (waiting on them) /
   green (done) — the house status meanings — and carries a glyph.
   Anchored down-and-inward, not centred: at `top: -8px` the 40px box starts
   1px BELOW the card's top edge, so nothing of it is outside the card. The
   track sets `overflow-x: auto`, which makes the block axis `auto` too, and
   anything hanging above the card would be clipped clean away. */
.suggest-rail [data-add] {
  position: absolute; top: -8px; inset-inline-end: -10px;
  width: 40px; height: 40px; padding: 0; margin: 0;
  border: 0; background: none; color: var(--on-accent); cursor: pointer;
  display: grid; place-items: start end;
}
.suggest-rail [data-add] > span {
  width: 20px; height: 20px; box-sizing: border-box; border-radius: var(--r-pill);
  display: grid; place-items: center;
  background: var(--a2);
  /* The ring is the card's own surface, the same trick the presence dot uses:
     without it a badge on a busy photo has no edge. */
  border: 2px solid var(--s1);
}
.suggest-rail [data-add][data-state="sent"] > span { background: var(--amber); }
.suggest-rail [data-add][data-state="friends"] > span { background: var(--green); }
/* Waiting and done are STATES, not buttons: the pointer says so, and the button
   is disabled so it is skipped rather than announced as an action that does
   nothing. Cancelling a request lives on the profile, where an undo belongs. */
.suggest-rail [data-add]:disabled { cursor: default; opacity: 1; }
.suggest-rail [data-add] svg {
  width: 12px; height: 12px; fill: none; stroke: currentColor;
  stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round;
}
/* The presence dot's ring is cut out of the card this avatar sits on. */
.suggest-rail .scard .av-wrap .online { border-color: var(--s1); }

/* The arrows exist only where the exactly-three layout does. Below 640px the
   peek plus a swipe is the affordance, and there is no room beside the label. */
.suggest-rail [data-arrows] { display: none; flex: 0 0 auto; gap: 2px; }
@media (min-width: 640px) {
  /* Three cards and two gaps = 100% of the column, so the fourth starts exactly
     at the edge and no fraction of it is drawn. */
  .suggest-rail { --sug-w: calc((100% - 2 * var(--sp-3)) / 3); }
  .suggest-rail [data-arrows] { display: flex; }
}
@media (min-width: 1512px) {
  .suggest-rail { --sug-w: calc((100% - 3 * var(--sp-3)) / 4); }
}
/* Nothing to scroll: the pair goes entirely, and it goes with `display` rather
   than `visibility`, or two invisible buttons stay in the tab order. The extra
   attribute is what lets this out-specify the breakpoint rule above. */
.suggest-rail [data-arrows][data-empty="1"] { display: none; }
.suggest-rail [data-nav] {
  display: inline-grid; place-items: center;
  width: 26px; height: 26px; flex: 0 0 auto;
  border: 0; border-radius: var(--r-sm); background: var(--s1); color: var(--dim);
  font-size: var(--fs-base); line-height: 1; cursor: pointer;
}
.suggest-rail [data-nav]:hover:not(:disabled) { background: var(--line); color: var(--ink); }
/* Disabled, not hidden: a pair that vanishes at the ends makes the header
   re-flow under the pointer that is still clicking it. */
.suggest-rail [data-nav]:disabled { opacity: .35; cursor: default; }
/* The glyph is a direction, so it flips with the writing direction. */
html[dir="rtl"] .suggest-rail [data-nav] { transform: scaleX(-1); }
/* LIST MODE — fewer than MIN_CAROUSEL suggestions. Same element, no scrolling:
   the strip becomes a column of full-width rows and the arrows go with it. At
   full width the ✕ moves out of the corner and to the END of the row (feed.js
   moves the node), because on a 600px row a corner ✕ sits an inch from the card
   it belongs to and points at nothing. */
.suggest-rail[data-mode="list"] .suggest-track {
  display: flex; flex-direction: column; overflow: visible;
  scroll-snap-type: none; margin: 0; padding: 3px 0;
}
.suggest-rail[data-mode="list"] [data-arrows] { display: none; }
.suggest-rail[data-mode="list"] .scard { width: 100%; align-items: center; }
.suggest-rail[data-mode="list"] .scard .l2 { display: block; white-space: nowrap; text-overflow: ellipsis; }
.suggest-rail[data-mode="list"] .scard .who { padding-inline-end: 0; }
.suggest-rail[data-mode="list"] [data-skip] {
  position: static; flex: 0 0 auto; font-size: var(--fs-row); padding: 4px 6px;
}

/* IN THE DESKTOP SIDE COLUMN (>=1024, feed.js `asideHostNow`) it is one of the
   rail's cards and must be built like one, or the column reads as two design
   languages arguing: `.railcard`'s surface and hairline outside, transparent
   rows with the ink-film hover inside. `--s3` is the SHELF's colour, and there
   is no shelf here — the aside is already the shelf. */
.aside .suggest-rail {
  background: var(--s1); border: 1px solid var(--line); box-shadow: var(--card-shadow);
  padding: var(--sp-2) var(--sp-5) var(--sp-4);
}
.aside .suggest-rail .scard {
  background: transparent; border-color: transparent; padding: 6px;
}
.aside .suggest-rail .scard:hover { background: var(--hover); border-color: transparent; }

/* ==========================================================================
   ความยาวโพสต์ + ซีรีส์
   ========================================================================== */

/* ---------- the live character counter ----------
   Neutral until it matters. Amber = "waiting on someone" is wrong here, so the
   near-limit state uses amber in its literal status meaning (something is about
   to need the writer's attention) and over-limit uses red (the send is refused).
   Both are the literal status colours, which must never re-skin with the accent. */
.counterbar { display: flex; flex-direction: column; gap: var(--sp-2); margin-top: var(--sp-2); }
.charcount {
  display: flex; align-items: center; justify-content: flex-end; gap: var(--sp-4);
  font-size: var(--fs-micro); color: var(--dim2); font-variant-numeric: tabular-nums;
}
.charcount.near .num { color: var(--amber); font-weight: 700; }
.charcount.over .num { color: var(--red); font-weight: 700; }
/* The limit could not be read from the server: the number on screen is the last
   known one, not a fact. Say so visually as well as in the title. */
.charcount.stale .num { color: var(--amber); text-decoration: underline dotted; }

/* ---------------------------------------------------------------------------
   Hashtags
   ---------------------------------------------------------------------------
   A tag in a body is a real <a href="#/tag/…">. It is coloured with the accent
   because it is a link and not a status: it re-skins with the user's accent
   like every other link, which is exactly right — nothing about a hashtag has
   a fixed meaning the way "waiting" or "sold out" does.

   `word-break` is inherited from `.post .body` and matters more here than
   anywhere else in the app: a Thai tag has no spaces in it, so an un-breakable
   `#ร้านอาหารโคราชอร่อยมาก` would push the card wider than the viewport.
--------------------------------------------------------------------------- */
a.tag {
  color: var(--a1);
  text-decoration: none;
  font-weight: 600;
  /* The label carries its own `#`, so no ::before — a generated glyph would be
     invisible to "copy link text" and to a screen reader's link list. */
}
a.tag:hover { text-decoration: underline; }
/* The stylesheet's own rule: anything that resets an outline restores one in
   the same rule. This does not reset one — it only strengthens the default. */
a.tag:focus-visible { outline: 2px solid var(--a2); outline-offset: 2px; border-radius: 4px; }

/* `@handle` in a body — same treatment as `a.tag` (accent, no underline until
   hover) so a mention reads as a link the same way a hashtag does; it is not
   a real `<a href>` to a page (see `tags.js`'s `renderBodyHtml`: the target
   id is resolved on click, not known at render time), so it earns its own
   selector rather than sharing `a.tag`'s. */
a.mention {
  color: var(--a1);
  text-decoration: none;
  font-weight: 600;
}
a.mention:hover { text-decoration: underline; }
a.mention:focus-visible { outline: 2px solid var(--a2); outline-offset: 2px; border-radius: 4px; }

/* The `#` typeahead under a composer. Inline, in normal flow — NOT a floating
   popup, so it can never cover the line being typed on a short phone screen. */
.tagpick { display: flex; flex-direction: column; gap: var(--sp-2); }
.tagpick .taglist {
  list-style: none; margin: 0; padding: var(--sp-2);
  background: var(--s2); border: 1px solid var(--line); border-radius: var(--r-md);
  max-height: 232px; overflow-y: auto;
}
.tagpick .taglist .row { cursor: pointer; border-radius: var(--r-sm); }
/* `.on` is the highlighted option. Focus stays in the textarea (this is a
   combobox, so selection travels by aria-activedescendant), which means this
   class is the ONLY thing telling a sighted user where Enter will land — it
   cannot be a subtle tint. */
.tagpick .taglist .row.on { background: var(--s3); outline: 2px solid var(--a2); outline-offset: -2px; }
.tagpick .taglist .dim { color: var(--dim2); font-size: var(--fs-micro); flex: 0 0 auto; }
/* The "which words just became a tag" preview — pink, same colour as the
   real `a.tag` link the body will render, so the promise ("this will turn
   pink") and the payoff (the actual post) use one colour. */
.tagpick .tagfound { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.tagpick .tagfound .tagchip {
  color: var(--a1); background: color-mix(in srgb, var(--a1) 14%, transparent);
  border-radius: var(--r-sm); padding: 1px 8px; font-size: var(--fs-micro);
  font-weight: 700; line-height: 1.6;
}
.tagpick .tagcap {
  font-size: var(--fs-micro); color: var(--dim2); text-align: end;
  font-variant-numeric: tabular-nums;
}
/* Over the per-post cap: literal status red, never accent-derived, because
   "these tags will not be saved" is a meaning and not a decoration. */
.tagpick .tagcap.bad { color: var(--red); font-weight: 700; text-align: start; }
.tagpick .hint.bad { color: var(--amber); }

/* The offer, at the moment the wall is hit: a series, not "delete words". */
.overhint {
  display: flex; align-items: center; gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-4); border-radius: var(--r-md);
  background: color-mix(in srgb, var(--red) 12%, transparent);
  color: var(--red); font-size: var(--fs-sm); font-weight: 600;
}
.overhint .grow { flex: 1; min-width: 0; }
.overhint .linkbtn { color: var(--red); font-size: var(--fs-sm); text-decoration: underline; }
.overhint .linkbtn:hover { color: var(--red); filter: brightness(1.15); }

/* A chip that is a button, not a select. `.chip` sets no outline, so the
   app-wide :focus-visible ring applies untouched. */
button.chip.pick {
  border: 1px solid var(--line); cursor: pointer;
  /* A long series title must not push the visibility chip and the post button
     off a 360px composer bar. */
  max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block;
}
button.chip.pick:hover { border-color: color-mix(in srgb, var(--a2) 40%, var(--line)); color: var(--ink); }
button.chip.pick.on { color: var(--a2); border-color: color-mix(in srgb, var(--a2) 50%, var(--line)); }

/* ---------- "this is EP 3 of …" on the card, before the tap ---------- */
/* "This one is an episode" used to be a tinted BORDER — which the continuous
   feed no longer has. It moves to a bar down the leading edge: same signal,
   louder than the tint ever was, and it needs no frame to hang on. A pseudo
   element rather than `border-inline-start`, because a real border would move
   the card's padding box and drag `.neonfx` (inset: 0) off the edge with it —
   an episode that is ALSO a neon post has to keep both, unchanged.
   `inset-inline-start` so RTL puts it on the right. z-index 2 keeps it under
   the neon frame (z-index 3) and over the card's own content. */
.post.ep::before {
  content: ""; position: absolute; inset-block: 0; inset-inline-start: 0;
  width: 3px; z-index: 2; pointer-events: none;
  background: color-mix(in srgb, var(--a2) 70%, transparent);
}
.serieschip {
  display: inline-flex; align-items: center; gap: var(--sp-3); max-width: 100%;
  margin-bottom: var(--sp-3); padding: 3px var(--sp-4); border-radius: var(--r-sm);
  border: 1px solid color-mix(in srgb, var(--a2) 35%, var(--line));
  background: color-mix(in srgb, var(--a2) 12%, transparent);
  color: var(--a2); font: inherit; font-size: var(--fs-micro); font-weight: 700; cursor: pointer;
}
.serieschip:hover { background: color-mix(in srgb, var(--a2) 20%, transparent); }
.serieschip .ttl { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }

/* ---------- the strip under an episode ---------- */
.epnav { margin: 0 var(--sp-6); }
.epnav-box {
  display: flex; flex-direction: column; gap: var(--sp-4);
  padding: var(--sp-5); border-radius: var(--r-lg);
  background: var(--s1); border: 1px solid var(--line);
}
.epnav-line { display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap; }
.epnav-line .grow { flex: 1; min-width: 0; }
.epnav-line .ep {
  flex: 0 0 auto; padding: 2px var(--sp-4); border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--a2) 16%, transparent); color: var(--a2);
  font-size: var(--fs-micro); font-weight: 700;
}
.epnav-acts { display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap; }
.linkbtn.strong { font-size: var(--fs-row); color: var(--ink); }
.muted { font-size: var(--fs-sm); color: var(--dim2); }

/* ---------- the contents screen ---------- */
.serieshead { padding: var(--sp-5) var(--sp-6) 0; }
.serieshead .cover {
  width: 100%; max-height: min(220px, 28vh); object-fit: cover;
  border-radius: var(--r-lg); background: var(--s3); margin-bottom: var(--sp-4);
}
.serieshead h2 { margin: 0; font-size: var(--fs-h); }
.serieshead .desc { margin: var(--sp-3) 0 0; font-size: var(--fs-row); color: var(--dim); line-height: 1.5; }
.serieshead .acts { display: flex; gap: var(--sp-4); margin-top: var(--sp-4); flex-wrap: wrap; }

/* `.row` is normally an <a> or a <div>; these two are <button>s, which inherit
   the UA's own font and colour. `.row` already resets text-align for that case;
   the rest belongs here. The spans inside need to be blocks, because `.row .l1`
   was written for the divs every other list uses. */
.row.seriesopt, .row.eprow { font: inherit; color: var(--ink); width: 100%; }
.row.seriesopt .l1, .row.seriesopt .l2,
.row.eprow .l1, .row.eprow .l2 { display: block; }
.row.eprow { align-items: flex-start; cursor: pointer; }
.row.eprow .epno {
  flex: 0 0 auto; margin-top: 1px; padding: 2px var(--sp-4); border-radius: var(--r-sm);
  background: var(--s3); color: var(--dim); font-size: var(--fs-micro); font-weight: 700;
}
.row.eprow .l1 { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.epnote {
  padding: var(--sp-4); font-size: var(--fs-micro); color: var(--dim2); line-height: 1.5;
}

/* ---------- the picker / editor dialogs ---------- */
.serieslist { display: flex; flex-direction: column; gap: var(--sp-3); max-height: 46vh; overflow-y: auto; margin-bottom: var(--sp-5); }
.row.seriesopt { cursor: pointer; }
.row.seriesopt.on { border-color: color-mix(in srgb, var(--a2) 50%, var(--line)); }
.row.seriesopt .tick { color: var(--green); font-weight: 700; flex: 0 0 auto; }
.dlgfield { display: block; text-align: start; margin-bottom: var(--sp-4); }
.dlgfield > span { display: block; font-size: var(--fs-sm); font-weight: 600; color: var(--dim); margin: 0 0 5px; }
.dlgerr {
  margin-bottom: var(--sp-4); padding: var(--sp-4); border-radius: var(--r-md);
  background: color-mix(in srgb, var(--red) 12%, transparent);
  color: var(--red); font-size: var(--fs-sm); text-align: start; overflow-wrap: anywhere;
}

.comment { display: flex; gap: var(--sp-4); padding: var(--sp-2) var(--sp-4); }
.comment .bubble { flex: 1; min-width: 0; background: var(--s3); border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4); }
.comment .name { font-size: var(--fs-sm); font-weight: 600; }
.comment .text { font-size: var(--fs-row); white-space: pre-wrap; word-break: break-word; }
.comment .when {
  font-size: var(--fs-micro); color: var(--dim2); margin-top: 2px;
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
}

/* A reply is indented ONE level and never more: the database flattens depth ≥ 3
   onto the same parent (flatten_comment_depth), so a third indent could not
   exist without lying about the stored structure. `margin-inline-start` keeps
   it mirrored under RTL — a physical margin-left would indent Arabic replies
   away from the thread they belong to. */
.comment.reply { margin-inline-start: 34px; }
.comment.reply .bubble { background: var(--s2); }

/* The inline actions under a comment: text, not chrome, so they do not compete
   with the comment itself. They are real <button>s and therefore owe a visible
   focus ring — this rule sets no `outline: none`, so the app-wide
   :focus-visible ring applies; the extra rule below only strengthens it. */
.linkbtn {
  background: none; border: 0; padding: 0; cursor: pointer;
  color: var(--dim); font: inherit; font-size: var(--fs-micro); font-weight: 600;
  border-radius: var(--r-sm);
}
.linkbtn:hover { color: var(--ink); text-decoration: underline; }
.linkbtn.on { color: var(--a2); }
/* "ลบ" — the one destructive action on a comment. Red is a status colour and is
   never accent-derived, so it stays red whatever accent the reader has chosen. */
.linkbtn.danger { color: var(--red); }
.linkbtn.danger:hover { color: var(--red); filter: brightness(1.15); }

/* The rows that reveal collapsed replies and the next page of root comments.
   Indented to sit exactly where the hidden rows would be, so each reads as part
   of the thread rather than as a control floating beside it. `.reply` matches
   `.comment.reply`'s indent, and uses `margin-inline-start` for the same RTL
   reason. */
.comment-more { padding: var(--sp-2) var(--sp-4) var(--sp-3); }
.comment-more.reply { margin-inline-start: 34px; }
.linkbtn:focus-visible { outline: 2px solid var(--a2); outline-offset: 2px; }

/* Attach-a-photo in the chat composer. The <input type="file"> is inside the
   <label>, sized to it and transparent, rather than `display:none` — a hidden
   input is not focusable, so a keyboard user could never reach the control. */
.attachbtn { position: relative; overflow: hidden; flex: 0 0 auto; cursor: pointer; }
.attachbtn input[type="file"] {
  position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%;
}
.attachbtn:focus-within { outline: 2px solid var(--a2); outline-offset: 2px; }

/* The chat composer is now a wrapper (pending photo above, input row below), so
   the top border moves to the wrapper — otherwise the chosen photo would sit
   ABOVE the line that is supposed to separate the composer from the messages. */
.chat-composer { background: var(--bg); border-top: 1px solid var(--line); }
.chat-composer > .composer-bar { border-top: 0; }

.attach-preview {
  display: flex; align-items: center; gap: 8px;
  padding: var(--sp-3) 16px 0;
  font-size: var(--fs-micro); color: var(--dim);
}
.attach-preview img {
  width: 44px; height: 44px; object-fit: cover; border-radius: var(--r-sm);
  border: 1px solid var(--line); flex: 0 0 auto;
}
.attach-preview .grow { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.commentbox { padding-inline: var(--sp-4); }
/* Sticky comment composer — FB/Android style: it stays pinned to the bottom
   of the SCROLLING ancestor (the window on `#/post/<id>`, `.lb-panel` inside
   the lightbox) instead of sitting wherever the comment list happens to end.
   It is the LAST node `mountPostComments` appends, so nothing in the thread
   ever renders below it — the sticking edge only ever covers comments
   scrolling PAST underneath it, never the final one, which is why no extra
   list padding was needed beyond what `.list` already carries.
   `bottom: var(--tabbar-h)` clears the phone tab bar on `#/post/<id>`
   (58px there, 0 at >=640px where the bar is hidden); inside the lightbox
   the viewer itself (`z-index: 90`) already sits above the tab bar, so that
   reservation would just be dead space — overridden to `bottom: 0` below. */
.commentbox-sticky {
  position: sticky;
  bottom: var(--tabbar-h);
  z-index: 2;
  background: var(--bg);
  border-top: 1px solid var(--line);
  margin-top: var(--sp-3);
  padding-top: var(--sp-3);
  padding-bottom: max(var(--sp-3), env(safe-area-inset-bottom));
}
.lb-panel-comments .commentbox-sticky { bottom: 0; background: var(--s2); }
.replyto {
  display: flex; align-items: center; gap: 8px;
  font-size: var(--fs-micro); color: var(--dim);
  padding: var(--sp-2) var(--sp-3); margin-block-end: var(--sp-2);
  background: var(--s2); border-radius: var(--r-md);
}
/* The reply label is the stretching column and "ยกเลิก" the fixed one. Written
   here, not as a `u-grow` on the element, because `feed.js` is owned by another
   pass today; the declarations are `.u-grow`'s, verbatim, plus the ellipsis a
   one-line label needs when someone replies to a long comment. */
.replyto .grow {
  flex: 1 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.replyto .linkbtn { flex: 0 0 auto; white-space: nowrap; }

/* ==========================================================================
   Chat
   ========================================================================== */
.chatroom {
  display: flex; flex-direction: column;
  height: calc(100dvh - var(--appbar-h) - var(--colhead-h) - var(--tabbar-h));
}
.msgs { flex: 1; overflow-y: auto; padding: var(--sp-4) 16px 12px; display: flex; flex-direction: column; gap: 6px; }
.msg { max-width: min(76%, 460px); align-self: flex-start; }
.msg.mine { align-self: flex-end; }
.msg .bubble {
  padding: 8px 12px;
  /* the flat corner points at the speaker, so it must follow the direction */
  border-radius: var(--r-lg); border-end-start-radius: 5px;
  background: var(--s2); font-size: var(--fs-body); word-break: break-word;
}
/* pre-wrap belongs to the MESSAGE, not to the bubble: on the bubble it also
   preserved the template literal's own newlines and indentation, which padded
   every incoming bubble out to three blank lines. */
.msg .bubble > span { white-space: pre-wrap; }
.msg.mine .bubble { background: var(--brand); color: #fff;
  border-radius: var(--r-lg); border-end-start-radius: 16px; border-end-end-radius: 5px; }
.msg .quote {
  border-inline-start: 3px solid currentColor; opacity: .75;
  padding-block: 2px; padding-inline: 8px 0;
  margin-bottom: 5px; font-size: var(--fs-base);
}
.msg .stamp { font-size: var(--fs-micro); color: var(--dim2); margin-top: 2px; padding: 0 4px; }
.msg.mine .stamp { text-align: end; }
.msg .att img { border-radius: var(--r-md); margin-top: 5px; max-height: 300px; }
.msg .deleted { font-style: italic; opacity: .6; }

/* Read receipt — the double tick on YOUR OWN messages. Two states and no third:
   --dim2 = delivered, --a3 = read. The same pair the Android client uses
   (NovaDim2 / NovaAccent3), because one tick must not mean two things.
   A button, not a span: the exact read time has to be reachable by tap and by
   keyboard, and the global :focus-visible ring at the top of this file applies
   because nothing here resets `outline`. */
.msg .rr {
  display: inline-flex; vertical-align: -2px; margin-inline-start: 4px;
  inline-size: 13px; block-size: 13px;
  padding: 0; border: 0; background: none; cursor: pointer;
  color: var(--dim2); transition: color .12s ease;
}
.msg .rr svg { inline-size: 100%; block-size: 100%; display: block; }
.msg .rr.read { color: var(--a3); }
.msg .rr:hover { color: var(--a2); }

.composer-bar {
  display: flex; gap: 8px; align-items: flex-end;
  padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
  border-top: 1px solid var(--line); background: var(--bg);
}
.composer-bar textarea {
  flex: 1; max-height: 120px; min-height: 42px; padding: var(--sp-4) var(--sp-5); border-radius: var(--r-xl);
  background: var(--s2); border: 1px solid var(--line); outline: none; resize: none;
}
.composer-bar textarea:focus-visible { border-color: var(--a2); outline: 2px solid var(--a2); outline-offset: 2px; }
.composer-bar .btn { flex: 0 0 auto; border-radius: var(--r-pill); width: 42px; height: 42px; padding: 0; }

/* ==========================================================================
   Profile
   ========================================================================== */
/* The head is the one block in the app that is CENTRED, and on a phone that is
   fine: at 358px of usable width the name fills the line and the parts sit on
   top of each other, so they read as one object. At 606px they do not — the
   avatar is 96px, the name 279px and the handle 65px, each an island centred in
   a box more than twice its width, with a uniform 8px between every one of
   them. Uniform spacing states that nothing is grouped, which is exactly what
   the owner saw ("แยกมาก", 2026-07-28).

   Two things fix it, and neither is a new layout:
   1. From 640 up the head becomes a CARD, the same object every other block on
      every other screen already is (.post, .railcard, .row). The empty space
      then reads as the card's padding instead of as distance between parts —
      the feed at this width is fine for precisely this reason.
   2. The gaps stop being uniform. Name + handle are one tight `.ident` group
      (2px), and 8/10px separates the GROUPS: cover, avatar, identity, bio,
      actions. Spacing is what says which things belong together.
   The phone keeps the layout it already had. */
.profile-head { padding: 16px; display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; }
@media (min-width: 640px) {
  .profile-head {
    margin-block: 8px 0; margin-inline: 16px; padding: 16px; gap: 10px;
    background: var(--s1); border: 1px solid var(--line);
    border-radius: var(--r-lg); box-shadow: var(--card-shadow);
  }
}
.profile-head .cover {
  width: 100%; height: 120px; border-radius: var(--r-lg); background: var(--brand-soft);
  border: 1px solid var(--line); overflow: hidden; margin-bottom: -54px;
}
@media (min-width: 640px) { .profile-head .cover { height: 148px; border-radius: var(--r-md); } }
.profile-head .cover img { width: 100%; height: 100%; object-fit: cover; }
/* The ring is the surface the avatar sits ON, which is the card from 640 up. */
.profile-head .av { border: 3px solid var(--bg); }
@media (min-width: 640px) { .profile-head .av { border-color: var(--s1); } }
/* Name and handle are one thing, not three. `min-width: 0` so a long unbroken
   korat_id ellipsises inside the group rather than stretching it. */
.profile-head .ident {
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  min-width: 0; max-width: 100%;
}
.profile-head h2 { margin: 0; font-size: var(--fs-h); max-width: 100%; overflow-wrap: anywhere; }
.profile-head .handle { font-size: var(--fs-base); color: var(--dim2); max-width: 100%; overflow-wrap: anywhere; }
.profile-head .bio { font-size: var(--fs-row); color: var(--dim); max-width: 46ch; white-space: pre-wrap; }
/* The edit form is the other half of the same complaint: bare labels and
   fields directly on the column background, with the head's centred alignment
   ending abruptly above them. Carded from 640 up so the screen is two objects
   rather than a scatter. */
.form { display: flex; flex-direction: column; gap: 12px; padding: 8px 16px; }
@media (min-width: 640px) {
  .form {
    margin-block: 8px 0; margin-inline: 16px; padding: 16px;
    background: var(--s1); border: 1px solid var(--line);
    border-radius: var(--r-lg); box-shadow: var(--card-shadow);
  }
}
.danger-zone { padding: 24px 16px 8px; display: flex; flex-direction: column; align-items: stretch; }

/* Actions on someone else's profile: add friend / accept / message. Centred to
   match .profile-head, and allowed to wrap — Arabic and German both make these
   labels long enough to overflow a 360px row otherwise.
   It now lives INSIDE the head (profile.js appends it there) so the buttons sit
   with the person they act on instead of floating under them; the standalone
   padding is only for the skeleton frame before the head exists. */
.profile-acts {
  display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
  padding: 0 16px 8px;
}
.profile-head .profile-acts { padding: 0; width: 100%; }
.profile-acts .btn { flex: 0 1 auto; }

/* The action cluster at the end of a list row. `flex: 0 0 auto` keeps it from
   being squeezed by a long name, and the row's own text ellipsises instead. */
.rowacts { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; }

/* ==========================================================================
   Auth
   ========================================================================== */
.auth { min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
.auth .card {
  width: 100%; max-width: 400px; background: var(--s1); border: 1px solid var(--line);
  border-radius: var(--r-xl); padding: var(--sp-7) 24px; box-shadow: var(--shadow);
}
.auth .mark { width: 60px; height: 60px; border-radius: var(--r-lg); margin: 0 auto 16px;
  background: url("/logo.png") center / cover no-repeat, var(--brand); }
.auth h1 { font-size: var(--fs-h); margin: 0 0 4px; text-align: center; }
.auth .lede { font-size: var(--fs-base); color: var(--dim); text-align: center; margin: 0 0 var(--sp-6); }
.auth form { display: flex; flex-direction: column; gap: var(--sp-4); }
.auth .alt { margin-top: var(--sp-5); text-align: center; font-size: var(--fs-base); color: var(--dim); }
.auth .alt button { background: none; border: 0; color: var(--a2); font-weight: 600; cursor: pointer; padding: 0; }
.auth .alt button:hover { text-decoration: underline; }
/* "Forgot password?" sits right under the submit button it answers, small and
   quiet — a recovery route, not a second way in competing with the primary
   button above it. */
.auth .fieldlink { margin: 0; text-align: center; font-size: var(--fs-sm); }
.auth .fieldlink button {
  background: none; border: 0; color: var(--dim); font-weight: 500; cursor: pointer; padding: 0;
}
.auth .fieldlink button:hover { color: var(--a2); text-decoration: underline; }
/* Two links on that one row ("ลืมรหัสผ่าน?" and the magic-link toggle) need a
   separator that is not a space — on a narrow phone they wrap and would read as
   one sentence. It hides itself when either link is hidden. */
.auth .fieldlink { display: flex; flex-wrap: wrap; justify-content: center; gap: 0 var(--sp-3); }
.auth .fieldlink .sep { color: var(--dim2, var(--dim)); }
/* Google and QR are two peer ways in — same button shape, same weight, so
   neither reads as an afterthought of the other. */
.authways { display: flex; flex-direction: column; gap: var(--sp-4); }
.authways .btn.ghost svg { width: 18px; height: 18px; flex: 0 0 auto; }
/* Sign-up is the one exit from this screen that is not "try again" — give it
   a hairline and real space so it reads as its own decision, not one more
   link in a stack. */
.auth .alt.signup {
  margin-top: var(--sp-7); padding-top: var(--sp-6); border-top: 1px solid var(--line);
  font-size: var(--fs-row);
}
.auth .alt.signup button { color: var(--ink); }
.auth .alt.signup button:hover { color: var(--a2); }
.auth .err {
  margin: 0 0 12px; padding: var(--sp-4) 12px; border-radius: var(--r-md); font-size: var(--fs-base);
  background: color-mix(in srgb, var(--red) 14%, transparent);
  color: var(--red); border: 1px solid color-mix(in srgb, var(--red) 32%, transparent);
  word-break: break-word;
}
.auth .ok {
  margin: 0 0 12px; padding: var(--sp-4) 12px; border-radius: var(--r-md); font-size: var(--fs-base);
  background: color-mix(in srgb, var(--green) 14%, transparent);
  color: var(--green); border: 1px solid color-mix(in srgb, var(--green) 32%, transparent);
}
/* Neutral. Backing out of Google's consent screen is a choice, not a fault, and
   painting it red says something went wrong when nothing did. */
.auth .note {
  margin: 0 0 12px; padding: var(--sp-4) 12px; border-radius: var(--r-md); font-size: var(--fs-base);
  background: var(--s3); color: var(--dim); border: 1px solid var(--line);
}

/* --------------------------------------------------------------------------
   Sign in with Google

   The surface, the text colour, the border and the mark are Google's, fixed by
   their branding rules — this is the one control on the screen that must NOT
   follow the accent or the elevation ladder, so it carries its own tokens.
   Dark is this app's default palette, so dark is the base here too and light is
   the override, matching the rest of the stylesheet. The radius is the pill
   Google permits, which is also the house button radius, so it still reads as
   part of the card rather than as a pasted-in widget.
   -------------------------------------------------------------------------- */
.gbtn {
  --g-bg: #131314; --g-ink: #E3E3E3; --g-line: #8E918F;
  display: flex; align-items: center; justify-content: center; gap: var(--sp-4);
  width: 100%; min-height: 42px; padding: var(--sp-4) 16px;
  border-radius: var(--r-btn); border: 1px solid var(--g-line);
  background: var(--g-bg); color: var(--g-ink);
  font-family: var(--font); font-size: var(--fs-row); font-weight: 500; line-height: 1.2;
  cursor: pointer;
  transition: background-color .12s ease, border-color .12s ease;
}
.gbtn:hover { background: color-mix(in srgb, var(--g-ink) 8%, var(--g-bg)); }
.gbtn:disabled { opacity: .55; cursor: not-allowed; }
/* An inline <svg> with only a viewBox has an intrinsic 300x150 box — state the size. */
.gbtn .gmark { width: 18px; height: 18px; flex: 0 0 auto; }
.gbtn span { min-width: 0; overflow-wrap: anywhere; }
@media (prefers-color-scheme: light) {
  :root[data-theme="auto"] .gbtn { --g-bg: #FFFFFF; --g-ink: #1F1F1F; --g-line: #747775; }
}
:root[data-theme="light"] .gbtn { --g-bg: #FFFFFF; --g-ink: #1F1F1F; --g-line: #747775; }

/* "or" — a rule with the word sitting in it, so the two ways in read as
   alternatives rather than as a list of steps. */
.auth .or {
  display: flex; align-items: center; gap: var(--sp-4);
  margin: 16px 0 12px; color: var(--dim2); font-size: var(--fs-sm);
}
.auth .or::before, .auth .or::after {
  content: ""; flex: 1 1 auto; height: 1px; background: var(--line);
}

/* --------------------------------------------------------------------------
   QR sign-in

   The QR itself is black on white and never themed: contrast here is a
   scanning requirement, not a style choice. Everything AROUND it follows the
   normal ladder, so the white square reads as a deliberate object on the card
   rather than a hole in the dark theme.
   -------------------------------------------------------------------------- */
.qrlogin { display: flex; flex-direction: column; gap: 10px; }
.qr-lede { margin: 0; font-size: var(--fs-base); font-weight: 500; color: var(--ink); text-align: center; }
.qr-note { margin: 0; font-size: var(--fs-xs); line-height: 1.4; color: var(--dim2); text-align: center; }
.qr-stage { display: grid; place-items: center; min-height: 240px; }
.qr-state { display: flex; flex-direction: column; align-items: center; gap: 10px; width: 100%; }

.qr-frame {
  width: 100%; max-width: 150px; aspect-ratio: 1 / 1;
  background: #fff; border-radius: var(--r-md); padding: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 1px var(--line);
}
.qr-frame svg { display: block; width: 100%; height: 100%; }
.qr-frame.empty {
  display: grid; place-items: center; font-size: 32px;
  background: var(--s2); box-shadow: inset 0 0 0 1px var(--line);
}

.qr-countdown { margin: 0; font-size: var(--fs-sm); font-weight: 600; color: var(--dim); }
.qr-countdown bdi { font-variant-numeric: tabular-nums; color: var(--ink); }
.qr-status { margin: 0; font-size: var(--fs-base); color: var(--dim); text-align: center; line-height: 1.5; }
.qr-status.ok { color: var(--green); }
.qr-status.err { color: var(--red); }
.qr-device { margin: 0; font-size: var(--fs-xs); line-height: 1.4; color: var(--dim2); text-align: center; }

/* The verify matching number card - Prominent, clean & sleek */
.qr-match-box {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  width: 100%; max-width: 220px; padding: 14px; border-radius: var(--r-lg, 16px);
  background: color-mix(in srgb, var(--accent) 8%, var(--s1));
  border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
  box-shadow: 0 8px 24px -4px color-mix(in srgb, var(--accent) 20%, transparent);
  margin-block: 8px;
}
.qr-matchlede {
  margin: 0 0 4px; font-size: 12px; font-weight: 600;
  color: var(--dim); text-align: center; line-height: 1.2; text-transform: uppercase; letter-spacing: .05em;
}
.qr-match {
  margin: 0; font-size: 40px; font-weight: 900; line-height: 1; letter-spacing: .08em;
  text-align: center; font-variant-numeric: tabular-nums;
  color: var(--accent);
}
.qr-match-warn {
  margin: 4px 0 0; font-size: 11px; font-weight: 500; line-height: 1.2;
  text-align: center; color: var(--amber);
}
.qr-note.warn { color: var(--amber); }
.qr-warn {
  margin: 0; padding: var(--sp-4) var(--sp-4); border-radius: var(--r-md); font-size: var(--fs-sm); line-height: 1.45;
  background: color-mix(in srgb, var(--amber) 13%, transparent);
  color: var(--amber); border: 1px solid color-mix(in srgb, var(--amber) 30%, transparent);
  text-align: center; word-break: break-word;
}
.qr-back-row {
  display: flex; justify-content: center; width: 100%; margin-top: 8px;
}
.qr-back-btn {
  width: 100%; max-width: 320px; height: 40px;
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  border-radius: var(--r-md); font-size: var(--fs-sm); font-weight: 600;
  color: var(--ink); background: var(--s2); border: 1px solid var(--line);
  cursor: pointer; transition: all 0.2s ease;
}
.qr-back-btn:hover { background: var(--s3); }
.qr-back-btn svg { width: 16px; height: 16px; color: var(--dim); }

/* ==========================================================================
   Toast + dialog + menu
   ========================================================================== */
.toast-host {
  position: fixed; inset-inline: 0;
  bottom: calc(var(--tabbar-h) + 16px + env(safe-area-inset-bottom, 0px));
  z-index: 60; display: flex; flex-direction: column; align-items: center; gap: 6px;
  pointer-events: none; padding: 0 16px;
}
.toast {
  max-width: 420px; padding: var(--sp-4) var(--sp-6); border-radius: var(--r-md); font-size: var(--fs-base);
  background: var(--s3); color: var(--ink); border: 1px solid var(--line);
  box-shadow: var(--shadow); word-break: break-word;
}
.toast.bad { background: color-mix(in srgb, var(--red) 18%, var(--s3)); border-color: var(--red); }
.toast.good { background: color-mix(in srgb, var(--green) 16%, var(--s3)); border-color: var(--green); }
/* amber = something is waiting or did not happen; not a failure, not an OK. */
.toast.warn { background: color-mix(in srgb, var(--amber) 16%, var(--s3)); border-color: var(--amber); }

.scrim {
  position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 24px;
  background: rgba(4, 5, 12, 0.58); backdrop-filter: blur(16px) saturate(140%);
  -webkit-backdrop-filter: blur(16px) saturate(140%);
}
.dialog {
  width: 100%; max-width: 380px; background: rgba(13, 14, 28, 0.78); backdrop-filter: blur(28px) saturate(190%);
  -webkit-backdrop-filter: blur(28px) saturate(190%);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: var(--r-xl); padding: var(--sp-6);
  box-shadow: 0 24px 64px -8px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.dialog h3 { margin: 0 0 8px; font-size: var(--fs-title); font-weight: 700; text-align: start; }
.dialog p { margin: 0 0 var(--sp-3); font-size: var(--fs-row); color: var(--dim); text-align: start; }
.dialog .keeps { font-size: var(--fs-base); color: var(--dim2); margin-bottom: 16px; }
.dialog .acts { display: flex; gap: 8px; justify-content: flex-end; }
.dialog .acts .btn.confirm-danger { background: var(--red); color: #fff; box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4); }

/* anchored popover menu (avatar dropdown) */
.menu {
  position: fixed; z-index: 80; min-width: 236px; max-width: min(300px, calc(100vw - 16px));
  padding: 6px; background: var(--glass-float); backdrop-filter: blur(20px);
  border: 1px solid var(--line-strong);
  /* `var(--shadow)`, not a literal 70% black: the light theme redefines the
     token to a soft 35% and a hardcoded one would drop a night-time shadow
     under a white popover. */
  border-radius: var(--r-lg); box-shadow: var(--shadow);
}
.menu .who { display: flex; align-items: center; gap: var(--sp-4); padding: 8px var(--sp-4) var(--sp-4); }
.menu .who .txt { min-width: 0; }
.menu .who .nm { font-size: var(--fs-row); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.menu .who .sub { font-size: var(--fs-sm); color: var(--dim2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.menu hr { border: 0; border-top: 1px solid var(--line); margin: 4px 2px; }
/* An item is a <button> or an <a role="menuitem"> — the link out to
   koratland.com has to be a real anchor so middle-click works, and it must not
   look like a different kind of row for it. */
.menu :is(button, a[role="menuitem"]) {
  display: flex; width: 100%; align-items: center; gap: var(--sp-4);
  padding: var(--sp-4) var(--sp-4); border: 0; border-radius: var(--r-md); cursor: pointer;
  background: transparent; color: var(--ink); font-size: var(--fs-row); text-align: start;
  text-decoration: none;
}
.menu :is(button, a[role="menuitem"]):hover { background: var(--s2); }
.menu :is(button, a[role="menuitem"]) > svg { width: 19px; height: 19px; flex: 0 0 auto; color: var(--dim); }
.menu button.danger > svg { color: var(--red); }
.menu :is(button, a[role="menuitem"]) > span:not(.val) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.menu button .val { margin-inline-start: auto; color: var(--dim2); font-size: var(--fs-base); flex: 0 0 auto; }
.menu button.danger { color: var(--red); }

/* wide content must scroll inside its own box, never the page */
.scroll-x { overflow-x: auto; max-width: 100%; }

/* ==========================================================================
   Desktop chat dock

   Pinned to the bottom-INLINE-END, so it sits bottom-right in Thai/English and
   bottom-left in Arabic with no RTL special case. It is `position: fixed` and
   overlays: it never pushes the page, so a new message cannot reflow whatever
   the reader is looking at.

   Absent below 1024px on purpose — there is already a bottom tab bar down
   there, and a floating panel would cover content to duplicate navigation that
   already exists.
   ========================================================================== */
.dock { display: none; }

@media (min-width: 1024px) {
  .dock {
    display: flex; align-items: flex-end; gap: 12px;
    position: fixed; bottom: 0; z-index: 55;
    /* Pinned to the SHELL's edge, not the viewport's. It was a flat 20px, which
       is right up to about 1400px and then walks away: on a 2560px monitor the
       dock sat ~480px clear of the right rail it is supposed to sit on top of,
       floating alone in the field with the app half a screen away. The max()
       keeps the old behaviour at every width where the shell already fills the
       window. */
    inset-inline-end: max(var(--shell-pad),
                          calc((100vw - var(--shell-w)) / 2 + var(--shell-pad)));
    pointer-events: none;                 /* only the panels take clicks */
  }
  .dock > * { pointer-events: auto; }
}

.dockcol { display: flex; flex-direction: column; align-items: stretch; gap: var(--sp-4); width: 232px; }

.dockbar {
  display: flex; align-items: center; gap: var(--sp-4);
  height: 44px; padding-inline: var(--sp-5); width: 100%;
  border: 1px solid var(--line); border-top-left-radius: 14px; border-top-right-radius: 14px;
  border-bottom: 0; border-end-start-radius: 0; border-end-end-radius: 0;
  background: var(--s1); color: var(--ink);
  font-size: var(--fs-row); font-weight: 700; cursor: pointer;
  box-shadow: var(--shadow);
}
.dockbar:hover { background: var(--s2); }
.dockbar.on { border-color: color-mix(in srgb, var(--a2) 45%, var(--line)); }
.dockbar svg { width: 20px; height: 20px; color: var(--dim); flex: 0 0 auto; }
.dockbar.on svg { color: var(--a2); }
.dockbar .lbl { flex: 1; min-width: 0; text-align: start; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dockbar .count {
  flex: 0 0 auto; min-width: 20px; height: 20px; padding: 0 6px; border-radius: var(--r-pill);
  background: var(--red); color: #fff; font-size: var(--fs-micro); font-weight: 800;
  line-height: 20px; text-align: center;
}

.docklist {
  display: flex; flex-direction: column;
  max-height: min(60vh, 460px); overflow: hidden;
  background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg);
  box-shadow: var(--shadow);
  animation: dock-in .16s ease-out;
}
.docklist header {
  display: flex; align-items: center; gap: 8px; padding: var(--sp-4) 12px 6px;
}
.docklist .ttl { font-size: var(--fs-base); font-weight: 700; color: var(--dim); flex: 1; min-width: 0; }
.docklist .lnk { font-size: var(--fs-sm); font-weight: 600; color: var(--a2);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.docklist .lnk:hover { text-decoration: underline; }
.docklist .body { overflow-y: auto; padding: 0 6px 8px; }
.docklist .list { padding: 0; gap: 2px; }
.docklist .row { width: 100%; border: 1px solid transparent; text-align: start; background: transparent; }
.docklist .row:hover { background: var(--s2); }
.docklist .state { padding: var(--sp-7) var(--sp-4); }
.docklist .state .icon { font-size: 20px; width: auto; height: auto; background: none; border: 0; }

/* ---------- a conversation window ---------- */
.dockwin {
  --dockwin-w: 320px;
  display: flex; flex-direction: column;
  width: var(--dockwin-w); height: 400px; max-height: min(70vh, 400px);
  background: var(--s1); border: 1px solid var(--line);
  border-top-left-radius: 16px; border-top-right-radius: 16px;
  border-end-start-radius: 0; border-end-end-radius: 0;
  box-shadow: var(--shadow); overflow: hidden;
  animation: dock-in .16s ease-out;
}
.dockwin.min { height: auto; max-height: none; }
.dockwin.min .dwbody { display: none; }

.dwhead {
  display: flex; align-items: center; gap: 8px; flex: 0 0 auto;
  padding: var(--sp-4) 8px; border-bottom: 1px solid var(--line); background: var(--s1);
}
.dwhead .nm {
  flex: 1; min-width: 0; border: 0; background: transparent; color: var(--ink);
  font-size: var(--fs-row); font-weight: 600; cursor: pointer; text-align: start;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0;
}
.dwhead .nm:hover { text-decoration: underline; }
.dwhead .ic {
  flex: 0 0 auto; width: 26px; height: 26px; border: 0; border-radius: var(--r-xs); cursor: pointer;
  background: transparent; color: var(--dim); font-size: var(--fs-base); line-height: 1;
  display: grid; place-items: center;
}
.dwhead .ic:hover { background: var(--s2); color: var(--ink); }

.dwbody { display: flex; flex-direction: column; min-height: 0; flex: 1; }
.dwbody .msgs { flex: 1; min-height: 0; padding: 8px var(--sp-4); }
.dwbody .msg { max-width: 86%; }
.dwbody .composer-bar { padding: 8px var(--sp-4); background: var(--s1); }
.dwbody .composer-bar textarea { min-height: 36px; padding: var(--sp-4) 12px; font-size: var(--fs-row); }
.dwbody .composer-bar .btn { width: 36px; height: 36px; }
.dwbody .state { padding: var(--sp-7) 12px; }
.dwbody .state .icon { font-size: 20px; width: auto; height: auto; background: none; border: 0; }
.dwbody .sk-row { padding: 6px 8px; }

@keyframes dock-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
  .docklist, .dockwin { animation: none; }
}

/* ==========================================================================
   Language picker
   ========================================================================== */
.iconbtn.lang { font-size: var(--fs-sm); font-weight: 800; letter-spacing: .04em; width: 34px; }
/* Outside the shell (the auth cards) the picker has no toolbar around it, so it
   needs a visible edge to read as a control rather than as a label. */
.langrow { display: flex; justify-content: center; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
.langrow .iconbtn.lang {
  width: auto; height: 34px; padding: 0 var(--sp-5);
  border: 1px solid var(--line); color: var(--a2);
}
/* The way back to koratland.com, on the signed-out cards. Same height and the
   same bordered treatment as the language chip beside it: they are two exits
   from the same dead end and neither is the primary action on the screen. */
.langrow .sitelink {
  display: inline-flex; align-items: center; gap: 6px;
  height: 34px; padding: 0 var(--sp-5); border-radius: var(--r-md);
  border: 1px solid var(--line); color: var(--a2);
  font-size: var(--fs-base); font-weight: 600; text-decoration: none; white-space: nowrap;
}
.langrow .sitelink:hover { background: var(--s2); }
.langrow .sitelink > svg { width: 15px; height: 15px; flex: 0 0 auto; }

.langlist { display: flex; flex-direction: column; gap: 4px; margin: 4px 0 var(--sp-5); }
.langopt {
  display: flex; align-items: center; gap: var(--sp-4); width: 100%;
  padding: 8px var(--sp-4); border-radius: var(--r-md); cursor: pointer;
  background: transparent; border: 1px solid transparent; color: var(--ink);
  font-size: var(--fs-row); text-align: start;
}
.langopt:hover { border-color: var(--line); background: var(--s3); }
.langopt.on { background: var(--s3); border-color: var(--a2); }
.langopt .code {
  flex: 0 0 auto; min-width: 30px; padding: 2px 6px; border-radius: var(--r-xs);
  background: var(--s3); color: var(--dim); font-size: var(--fs-micro); font-weight: 800; text-align: center;
}
.langopt.on .code { background: var(--a2); color: #fff; }
.langopt .grow { flex: 1; min-width: 0; }
.langopt .tick { color: var(--green); font-weight: 700; }

/* ==========================================================================
   Right-to-left

   `dir="rtl"` on <html> (set by i18n.js) does the mirroring; everything above
   is already written in logical properties. What direction cannot do on its own
   is flip a GLYPH that means "the way you came" or "the way this goes" — an
   arrow pointing left in an Arabic layout points forward, which is the opposite
   of what it says.
   ========================================================================== */
html[dir="rtl"] [data-back] svg,
html[dir="rtl"] .composer-bar .btn svg,
html[dir="rtl"] .post .act[data-comments] svg { transform: scaleX(-1); }

/* ==========================================================================
   Long-string safety

   Arabic, German and Russian all run noticeably longer than Thai, and a server
   error can arrive as an unbroken URL. Nothing here may widen the page: the
   body must never scroll sideways in ANY language.
   ========================================================================== */
.state .headline, .state .detail, .banner .grow, .toast,
.auth .err, .auth .ok, .dialog p { overflow-wrap: anywhere; }

/* `.consent-doc` (account.js, personal.js): a legal-consent sheet body — same
   text/actions shape as `.dialog` but rendered inside `openSheet()`, which
   gives it none of `.dialog`'s rules. Scoped so it cannot touch any other
   sheet. */
.consent-doc p { margin: 0 0 var(--sp-3); font-size: var(--fs-row); color: var(--dim); text-align: start; overflow-wrap: anywhere; }
.consent-doc .acts { display: flex; gap: 8px; justify-content: flex-end; margin-top: var(--sp-4); }
.qr-status, .qr-device, .qr-note { overflow-wrap: anywhere; }

/* ==========================================================================
   Commerce surfaces — the business console, the shop and the group screens.

   Everything below reuses the existing ladder (.row, .list, .chip, .btn,
   .state) and only adds the shapes those primitives cannot make: an order card
   with its own line items, a form laid out in pairs, a report tile, and a
   seven-bar chart. No new colour is invented — the status palette is literal
   and already defined at the top of this file.
   ========================================================================== */

/* ---------- toolbar above a list ---------- */
.toolbar { display: flex; gap: 8px; align-items: center; padding: 8px 16px 0; flex-wrap: wrap; }
.toolbar .field { flex: 1 1 180px; min-width: 0; }
.toolbar .btn { flex: 0 0 auto; }

/* ---------- a card that carries more than a row can ---------- */
.bizcard {
  background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg);
  padding: 12px; box-shadow: var(--card-shadow);
  display: flex; flex-direction: column; gap: 8px;
}
.bizcard-head { display: flex; align-items: flex-start; gap: 8px; }
.bizcard-head .grow { flex: 1; min-width: 0; }
.bizcard .l1 { font-size: var(--fs-row); font-weight: 600; overflow-wrap: anywhere; }
.bizcard .l2 { font-size: var(--fs-sm); color: var(--dim); }

.orderlines { display: flex; flex-direction: column; gap: 6px; }
.orderline { display: flex; align-items: center; gap: 8px; }
.orderline .thumb {
  width: 36px; height: 36px; flex: 0 0 auto; border-radius: var(--r-xs); overflow: hidden;
  background: var(--s3); display: grid; place-items: center; font-size: var(--fs-body);
}
.orderline .thumb img { width: 100%; height: 100%; object-fit: cover; }
.orderline .grow { flex: 1; min-width: 0; }
.orderline .nm { font-size: var(--fs-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.orderline .sub { font-size: var(--fs-micro); color: var(--dim2); }
.orderline .qty { font-size: var(--fs-sm); font-weight: 700; color: var(--dim); flex: 0 0 auto; }
.orderline .amt { font-size: var(--fs-sm); flex: 0 0 auto; font-variant-numeric: tabular-nums; }

.facts { display: flex; flex-direction: column; gap: 2px; border-top: 1px solid var(--line); padding-top: 8px; }
.facts .fact { font-size: var(--fs-sm); color: var(--dim); overflow-wrap: anywhere; }
.facts .fact a { color: var(--a3); }
.facts .track { color: var(--a1); font-weight: 600; }
.facts .bad { color: var(--red); font-size: var(--fs-micro); }

.totals { display: flex; flex-direction: column; gap: 2px; }
.tline { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-4); font-size: var(--fs-sm); color: var(--dim); }
.tline > span:last-child { font-variant-numeric: tabular-nums; flex: 0 0 auto; }
.tline.grand { font-size: var(--fs-row); color: var(--ink); font-weight: 700; }
.tline .amt { color: var(--a1); font-weight: 700; }
.bizcard .acts, .editor .acts, .hours .acts { display: flex; gap: 8px; flex-wrap: wrap; }
.bizcard .acts .btn { padding-block: 6px; }

/* ---------- forms ---------- */
.editor { display: flex; flex-direction: column; gap: var(--sp-4); }
.editor .label, .hours .label { display: flex; flex-direction: column; gap: 4px; }
.pair { display: grid; grid-template-columns: 1fr; gap: var(--sp-4); }
@media (min-width: 520px) { .pair { grid-template-columns: 1fr 1fr; } }
.hint { margin: 0; font-size: var(--fs-sm); color: var(--dim2); overflow-wrap: anywhere; }
.hint.warn { color: var(--amber); }
.hint.good { color: var(--green); }
/* A server refusal, sitting where the form can still be corrected. It stays on
   screen — the RPCs answer with the time the refusal ends ("ว่างอีกครั้งถ้า
   เช็คอิน 15/08/2026"), which is unreadable in a toast that fades. */
.hint.bad { color: var(--red); }
/* personal.js — a failed private-fields merge sitting as its own row, tap to
   retry. Must not look like an ordinary row: a failed read and "no data" must
   never be visually the same thing (CLAUDE.md). */
.row.bad { background: color-mix(in srgb, var(--amber) 12%, transparent); }
.row.bad .l1 { color: var(--amber); font-weight: 700; }
.check { display: flex; align-items: center; gap: 8px; font-size: var(--fs-base); color: var(--dim); }
/* An option row inside the menu-item sheet: name on the left, "+฿20" pushed to
   the right, so the price of a topping is read before it is ticked. */
.check .grow { flex: 1; min-width: 0; }
.check .amt { font-variant-numeric: tabular-nums; color: var(--dim2); flex: 0 0 auto; }
.check input { width: 17px; height: 17px; accent-color: var(--a2); flex: 0 0 auto; }
.photo { display: flex; align-items: center; gap: var(--sp-4); }
.photo .shot {
  width: 64px; height: 64px; flex: 0 0 auto; border-radius: var(--r-md); overflow: hidden;
  background: var(--s3); display: grid; place-items: center; font-size: 20px;
}
.photo .shot img { width: 100%; height: 100%; object-fit: cover; }

/* Opening hours: the two time fields must stay side by side down to 360px,
   so they shrink rather than wrap — a day split over two lines stops reading
   as one range. */
.hourrow { flex-wrap: nowrap; gap: 6px; }
.hourrow .grow { flex: 1 1 60px; min-width: 0; }
.hourrow .time { flex: 0 1 92px; min-width: 0; padding: 6px 8px; font-size: var(--fs-base); }
.hourrow .dash { color: var(--dim2); flex: 0 0 auto; }
.hourrow .check span { display: none; }
@media (min-width: 560px) { .hourrow .check span { display: inline; } }
.hours { display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-4) 16px 0; }
.lastorder { max-width: 320px; }

/* ---------- ที่พัก: the room board (app/stay.js) ----------
   Deliberately long, specific class names. `.bar` is already recorded in this
   file as the cost of a bare, generic word in a shared stylesheet — `.room`
   and `.cell` are exactly that shape of word, so the board uses `.roomgrid` /
   `.roomcell` and nothing else can collide with them.

   Free/occupied are the status palette's green and red, LITERAL — "somebody is
   in this room" is a meaning, not a theme, and it must not re-skin with the
   user's accent. The tile is square so a floor of rooms reads as a plan rather
   than a list, and it auto-fills so 6 rooms and 60 both look deliberate. */
.roomgrid {
  display: grid; gap: 8px; padding: var(--sp-4) 16px 0;
  grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
}
.roomcell {
  aspect-ratio: 1; min-width: 0; border-radius: var(--r-md); border: 1px solid var(--line);
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
  padding: 4px; cursor: pointer; font: inherit; text-align: center;
}
.roomcell .no { font-size: var(--fs-row); font-weight: 800; line-height: 1.1; }
.roomcell .who { font-size: var(--fs-micro); font-weight: 700; max-width: 100%;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.roomcell .ty { font-size: var(--fs-micro); color: var(--dim2); max-width: 100%;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.roomcell.free { background: color-mix(in srgb, #22C55E 14%, transparent); color: var(--ink); }
.roomcell.free .who { color: #22C55E; }
.roomcell.busy { background: color-mix(in srgb, #EF4444 14%, transparent); color: var(--ink); }
.roomcell.busy .who { color: #EF4444; }

/* ---------- report ---------- */
.report { display: flex; flex-direction: column; gap: 12px; padding: var(--sp-4) 16px 0; }
.report .hero {
  border-radius: var(--r-lg); padding: var(--sp-5); color: #fff;
  background: linear-gradient(105deg, #0EA5E9, #2563EB);
}
.report .hero .cap { font-size: var(--fs-base); opacity: .85; }
.report .hero .big { font-size: var(--fs-hero); font-weight: 800; line-height: 1.15; overflow-wrap: anywhere; }
.report .hero .sub { font-size: var(--fs-base); opacity: .9; }
.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.stat { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-md); padding: var(--sp-4); min-width: 0; }
.stat .cap { font-size: var(--fs-micro); color: var(--dim); }
.stat .val { font-size: var(--fs-row); font-weight: 700; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.panel { background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg); padding: var(--sp-5);
  display: flex; flex-direction: column; gap: 8px; }
.panel h3 { margin: 0; font-size: var(--fs-row); font-weight: 700; }
/* SCOPED TO `.bars`, and it has to be.
   `.bar` was a bare class here, and `class="bar"` is also the action toolbar of
   the feed composer (feed.js) and the group composer (groups.js). `.composer
   .bar` only declares display/align-items/gap/margin, so `flex-direction:
   column` and `height: 100%` from this rule leaked straight through and stood
   the composer's toolbar UP: the photo button, the visibility select and the
   Post button were stacked vertically, 120px tall where the row needs 40. That
   is ~80px pushed under the composer on every feed and every group, at every
   width, and it is most of why the top of the page looked padded out.
   A bare, generic class name in a shared stylesheet is a collision waiting for
   the next screen that needs the same English word. */
.bars { display: flex; align-items: flex-end; gap: var(--sp-3); height: 100px; }
.bars .bar { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center;
  justify-content: flex-end; height: 100%; }
.bars .bar .fill { width: 70%; border-radius: var(--sp-2) var(--sp-2) 0 0; background: color-mix(in srgb, var(--sky) 45%, transparent); }
.bars .bar .fill.now { background: var(--a1); }
.bars .bar .lbl { font-size: var(--fs-micro); color: var(--dim2); padding-top: 3px; }

/* ---------- bottom sheet (the app's modal editor, on the web's dialog surface) ---------- */
.sheet-scrim { align-items: end; padding: 0; }
@media (min-width: 640px) { .sheet-scrim { align-items: center; padding: 24px; } }
.sheet {
  width: 100%; max-width: 560px; max-height: 88dvh; display: flex; flex-direction: column;
  background: var(--s1); border: 1px solid var(--line);
  border-radius: var(--r-xl) 20px 0 0; box-shadow: var(--shadow);
}
@media (min-width: 640px) { .sheet { border-radius: var(--r-xl); } }
.sheet-head { display: flex; align-items: center; gap: 8px; padding: 12px 12px 4px; }
.sheet-head h3 { margin: 0; flex: 1; min-width: 0; font-size: var(--fs-title); }
.sheet-body { overflow-y: auto; padding: 8px 16px var(--sp-6); }
.scrim .dialog .field { width: 100%; margin: 8px 0 var(--sp-5); }

/* ---------- สร้างโพสต์ใหม่ — the compose modal (feed.js, openComposeModal) ----
   The SAME composer node the feed mounts inline, put inside the shared bottom
   sheet so the ✏ button works from every screen. Nothing here re-styles a
   control: it only undoes the composer's own "card in a page" chrome (it is the
   sheet's whole body now, so its margin, border and second background would
   draw a card inside a card) and keeps the publish row reachable on a phone.
   The sheet already clamps itself to the VISIBLE viewport (`--vv-h`), so the
   keyboard shrinks the box rather than hiding its bottom row — see
   `fitScrimToVisualViewport` in ui.js. */
.compose-scrim .sheet { max-height: min(calc(94dvh - var(--safe-b)), 100%); }
.compose-scrim .sheet-body { padding-inline: var(--sp-5); }
.compose-scrim .composer {
  margin: 0; padding: 0; border: 0; background: none; box-shadow: none; backdrop-filter: none;
}
.compose-scrim .composer:hover { border-color: transparent; }
/* The textarea autogrows to 220px in JS. Inside the modal that is measured
   against the sheet instead: on a 360×640 phone with the keyboard up, a box
   that grows to 220px pushes the tool row and the publish button under the
   keyboard even though the sheet itself is sized correctly. `max-height` beats
   the inline `height` the autogrow sets, so the box simply scrolls at its cap
   and the row below it stays put. */
.compose-scrim .composer textarea { max-height: max(120px, calc(var(--vv-h, 100dvh) * 0.32)); }
@media (min-width: 640px) { .compose-scrim .sheet { max-height: min(720px, 88dvh); } }

/* ---------- premium plan picker sheet (koratid_premium.js) ----------
   The generic `.sheet-head` puts the title first and the close button last —
   right for a form's editor, wrong here: the reference layout (Meta Verified /
   FB Plus) closes top-LEFT and keeps "see all plans" top-RIGHT, with the plan
   name centred between them. koratid_premium.js reorders the same three
   nodes after calling openSheet() rather than duplicating the sheet chrome,
   so this file only needs to make the reordered row look right. */
.plan-sheet .sheet-head { padding: 10px 8px 6px; }
.plan-sheet .sheet-head h3 { text-align: center; font-size: var(--fs-row); font-weight: 700; }
.plan-sheet .sheet-body { padding: 0 var(--sp-6) 0; display: flex; flex-direction: column; }

.plan-hero { padding: var(--sp-5) 0 var(--sp-4); text-align: center; }
.plan-hero .plan-name { font-size: var(--fs-h); font-weight: 800; color: var(--ink); margin: 0 0 2px; }
.plan-hero .plan-tag { font-size: var(--fs-sm); color: var(--dim); margin: 0 0 var(--sp-5); }
.plan-hero .plan-price { font-size: var(--fs-hero); font-weight: 800; color: var(--ink); line-height: 1.1; }
.plan-hero .plan-price small { font-size: var(--fs-base); font-weight: 500; color: var(--dim); }
.plan-hero .plan-tax { font-size: var(--fs-micro); color: var(--dim2); margin: 2px 0 0; }
.plan-hero .chip { margin-top: var(--sp-4); }

.plan-notforsale { display: inline-flex; align-items: center; gap: 4px; margin-top: var(--sp-4);
  padding: 4px var(--sp-4); border-radius: var(--r-pill); font-size: var(--fs-sm); font-weight: 600;
  background: color-mix(in srgb, var(--amber) 16%, transparent); color: var(--amber); }

.plan-switch { display: flex; gap: var(--sp-3); overflow-x: auto; padding: 2px 0 var(--sp-4); scrollbar-width: none; }
.plan-switch::-webkit-scrollbar { display: none; }

.plan-groups { display: flex; flex-direction: column; gap: var(--sp-5); padding-bottom: var(--sp-6); }
.plan-group-label { margin: 0; font-size: var(--fs-base); font-weight: 700; color: var(--dim); padding: 0 2px; }
.plan-group-card { background: var(--s2); border: 1px solid var(--line); border-radius: var(--r-lg);
  overflow: hidden; display: flex; flex-direction: column; }
.plan-feat { display: flex; align-items: flex-start; gap: var(--sp-4); padding: var(--sp-4) var(--sp-5);
  border: 0; background: transparent; width: 100%; text-align: start; color: var(--ink); cursor: default; }
.plan-group-card .plan-feat + .plan-feat { border-top: 1px solid var(--line); }
button.plan-feat { cursor: pointer; }
button.plan-feat:hover { background: var(--hover); }
.plan-feat .plan-feat-ic { flex: 0 0 auto; width: 22px; height: 22px; display: flex; align-items: center;
  justify-content: center; font-size: 15px; line-height: 1; }
.plan-feat .plan-feat-ic.check { color: var(--green); }
.plan-feat .plan-feat-body { flex: 1; min-width: 0; }
.plan-feat .plan-feat-label { font-size: var(--fs-row); font-weight: 500; }
.plan-feat .plan-feat-detail { font-size: var(--fs-sm); color: var(--dim); margin-top: 2px; overflow-wrap: anywhere; }

/* cheapest plan: a plain checklist, no group card chrome */
.plan-checklist { display: flex; flex-direction: column; gap: 2px; padding-bottom: var(--sp-6); }
.plan-checklist .plan-feat { padding-inline: 2px; }

.plan-quote { padding: var(--sp-4) var(--sp-5); margin-bottom: var(--sp-5); border-radius: var(--r-md);
  background: var(--s2); border: 1px solid var(--line); font-size: var(--fs-sm); color: var(--dim); }
.plan-quote strong { color: var(--ink); font-size: var(--fs-row); }

/* sticky purchase footer — same "sticky bottom of a scrolling sheet" shape as
   `.buybar`, but without that class's page tab-bar padding (there is no tab
   bar inside a sheet), and living in `.sheet-body` so it always stays in view
   without a second scroll container. */
/* `padding-bottom` carries `--safe-b` itself: `.plan-sheet .sheet-body` (0,2,0)
   replaces the whole `padding` shorthand, so the `.sheet-body` rule that adds
   the home-indicator inset (line ~5389, specificity 0,1,0) never applies here —
   the buy button sat under the gesture bar on a phone with no visible bezel. */
.plan-buyfoot { position: sticky; bottom: 0; z-index: 1;
  margin: 0 calc(var(--sp-6) * -1); padding: var(--sp-5) var(--sp-6) calc(var(--sp-5) + var(--safe-b));
  background: var(--s1); border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: var(--sp-4); }
.plan-buyfoot .plan-terms { margin: 0; font-size: var(--fs-micro); color: var(--dim2); text-align: center; }
.plan-buyfoot .plan-terms a { color: var(--dim); text-decoration: underline; }

/* plan list view ("ดูแผนทั้งหมด") */
.plan-list { display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-4) 0 calc(var(--sp-6) + var(--safe-b)); }
.plan-list-hint { margin: 0; text-align: center; }
.plan-list-row { display: flex; align-items: center; gap: var(--sp-4); width: 100%; text-align: start;
  background: var(--s2); border: 1px solid var(--line); border-radius: var(--r-lg); padding: var(--sp-5); cursor: pointer; }
.plan-list-row:hover { border-color: color-mix(in srgb, var(--a2) 40%, var(--line)); }
.plan-list-row.current { border-color: var(--a2); background: color-mix(in srgb, var(--a2) 8%, var(--s2)); }
.plan-list-row .grow .l1 { display: flex; align-items: center; gap: 6px; }
/* The price column is `.pricecol` now — the shared contract at the end of this
   file. Both rules that used to live here are DELETED rather than left to lose
   a cascade fight: `.plan-list-row .price small` is specificity (0,2,1) and
   would have beaten `.pricecol small` (0,1,1), so `display: block` would have
   gone on splitting "/ปี" onto its own line no matter what the new rule said.
   A shared contract that an older, more specific rule silently overrides is
   not a shared contract. */

/* ---------- long strings ---------- */
.bizcard .l1, .orderline .sub, .panel .tline span { overflow-wrap: anywhere; }

/* ==========================================================================
   Shop — browse, product, cart, order.

   The product grid is the one place this app leaves the single-column list: a
   product is chosen by its photo, and a 640px column showing one tile per row
   makes browsing thirty of them a scroll marathon. It starts at two columns
   even at 360px, because two thumbnails are still legible and one is a list
   pretending to be a grid.
   ========================================================================== */

.pgrid {
  display: grid; gap: 8px; padding: 8px 16px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 520px) { .pgrid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

.pcard {
  display: flex; flex-direction: column; gap: 3px; min-width: 0;
  background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-md);
  padding: 8px; box-shadow: var(--card-shadow);
}
.pcard:hover { border-color: color-mix(in srgb, var(--a2) 34%, var(--line)); }
.pcard .shot {
  position: relative; aspect-ratio: 1 / 1; border-radius: var(--r-sm); overflow: hidden;
  background: var(--s3); display: grid; place-items: center; font-size: 24px;
}
.pcard .shot img { width: 100%; height: 100%; object-fit: cover; }
.pcard .badge {
  position: absolute; inset-inline-start: 5px; inset-block-start: 5px;
  padding: 2px 6px; border-radius: var(--r-xs); font-size: var(--fs-micro); font-weight: 800; color: #fff;
}
.pcard .badge.sale { background: var(--red); }
/* Sold out covers the photo rather than sitting beside it: the state has to
   arrive before the tap, not after it. */
.pcard .badge.out {
  inset: 0; inset-inline-start: 0; display: grid; place-items: center; border-radius: var(--r-sm);
  background: rgba(4,4,10,.6); font-size: var(--fs-sm);
}
.pcard.out .nm, .pcard.out .pr { opacity: .6; }
.pcard .nm { font-size: var(--fs-base); font-weight: 500; line-height: 1.35;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.pcard .pr { font-size: var(--fs-base); }
.pcard .pr s { color: var(--dim2); font-size: var(--fs-micro); }
.pcard .pr b { color: var(--a1); }
.pcard .by { font-size: var(--fs-micro); color: var(--dim2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* ---------- shop header ---------- */
.shophead { display: flex; flex-direction: column; gap: 8px; padding: 8px 16px 0; }
.shophead .cover { height: 120px; border-radius: var(--r-lg); overflow: hidden; background: var(--s3); }
.shophead .cover img { width: 100%; height: 100%; object-fit: cover; }
.shophead .meta .l1 { font-size: var(--fs-title); font-weight: 700; overflow-wrap: anywhere; }
.shophead .meta .l2 { font-size: var(--fs-sm); color: var(--dim); overflow-wrap: anywhere; }

/* ==========================================================================
   ▼▼▼ BEGIN — the BUSINESS PAGE (#/food/<id> · #/stay/<id> · #/services/<id>).
        Owned by book.js and by nothing else. Every selector in this block is
        under `.shoppage`, so it cannot reach the retail shop (`.shophead`,
        `.pgrid`, `.pcard` above), the shell, or the profile page. Do not add a
        rule here that is not scoped to `.shoppage`.

   What the block is FOR. The page was a phone list stretched down a desktop
   column: a cover, then hours / address / phone / description as four
   identical grey sentences, then menu items as thin 40px-avatar rows. Nothing
   was grouped and nothing was emphasised, so the whole page read as a list of
   facts rather than as a restaurant. The fix is hierarchy, in three tiers:

     1. HERO — name, kind, open state, rating. The three things a customer came
        for, at three different weights.
     2. FACTS — hours, address, phone, description. Reference material, set as
        a definition list so it reads as reference material.
     3. MENU — the reason the page exists, and therefore the only part that
        gets a grid: a photo big enough to choose from, a price at a size worth
        reading, and sold-out over the photo rather than beside the price.

   Sparse is the ordinary case, not the exception. A shop with no cover, no
   description, no rating, no phone and no menu must still look finished, so
   every element here is conditional in the markup and nothing below draws a
   placeholder, a zero or a dash.
   ========================================================================== */

/* ---------- hero ---------- */
.shoppage .shophero { padding: var(--sp-4) var(--sp-6) 0; }

.shoppage .shophero-cover {
  height: 132px; border-radius: var(--r-lg); overflow: hidden; background: var(--s3);
  display: grid; place-items: center;
}
/* One step up per breakpoint, like every other ladder in this file. */
@media (min-width: 640px) { .shoppage .shophero-cover { height: 200px; } }
.shoppage .shophero-cover img { width: 100%; height: 100%; object-fit: cover; }
/* No photo is not a broken photo. A tinted panel carrying the kind's own mark
   reads as "this shop has not added one" rather than as a failed image, and it
   holds the hero's shape so a sparse shop is not a different layout. */
.shoppage .shophero-cover.noimg { background: var(--brand-soft); font-size: 40px; }
@media (min-width: 640px) { .shoppage .shophero-cover.noimg { font-size: 56px; } }

.shoppage .shophero-body {
  position: relative; display: flex; flex-direction: column; gap: var(--sp-2);
  padding: 0 var(--sp-2);
}
/* The logo laps the cover, which is what makes the two read as one object
   instead of a photo with a card under it. `margin-top` is negative by half
   the avatar so the overlap survives any cover height. */
.shoppage .shophero-logo { margin-top: -28px; margin-bottom: var(--sp-2); }
.shoppage .shophero-logo .av {
  width: 56px; height: 56px; font-size: var(--fs-h);
  border: 3px solid var(--bg); border-radius: var(--r-lg);
}
.shoppage .shophero-name {
  margin: 0; font-size: var(--fs-h); font-weight: 800; line-height: 1.25;
  overflow-wrap: anywhere;
}
@media (min-width: 640px) { .shoppage .shophero-name { font-size: 24px; } }
.shoppage .shophero-kind {
  margin: 0; font-size: var(--fs-base); font-weight: 600; color: var(--dim);
  overflow-wrap: anywhere;
}

.shoppage .shophero-state {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-3);
  margin-top: var(--sp-2);
}
.shoppage .shophero-state .sub { font-size: var(--fs-sm); color: var(--dim); }
/* `.chip` with no tone modifier is the neutral rung — that is the "hours were
   never set" answer, and it must not borrow green or red, because neither is
   what we know. */
.shoppage .shophero-state .rate { display: inline-flex; align-items: baseline; gap: var(--sp-2); }
.shoppage .shophero-state .rate b { font-size: var(--fs-row); font-weight: 700; }
/* Amber is the status palette's own value and is the same glyph colour the
   public shop page uses; a star that is accent-coloured would re-skin. */
.shoppage .shophero-state .stars { color: var(--amber); letter-spacing: .5px; font-size: var(--fs-sm); }
.shoppage .shophero-state .rate small { font-size: var(--fs-micro); color: var(--dim2); }

.shoppage .shophero-acts { display: flex; flex-wrap: wrap; gap: var(--sp-3); margin-top: var(--sp-3); }
.shoppage .shophero-acts:empty { display: none; }

/* ---------- facts: reference material, and it must read as reference ---------- */
.shoppage .shopfacts {
  margin: var(--sp-5) var(--sp-6) 0; padding: var(--sp-5);
  background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg);
  box-shadow: var(--card-shadow);
}
.shoppage .shopfacts .desc {
  margin: 0 0 var(--sp-4); font-size: var(--fs-row); color: var(--ink);
  white-space: pre-wrap; overflow-wrap: anywhere;
}
/* The full write-up sits under the one-line summary and is deliberately quieter:
   the short description is the claim, this is the elaboration. Same wrapping
   rules, because a 500-character paragraph pasted from elsewhere will contain
   line breaks and unbroken URLs just as often as the short one does. */
.shoppage .shopfacts .desc-long {
  margin: calc(var(--sp-4) * -1) 0 var(--sp-4); font-size: var(--fs-row); color: var(--dim);
  white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6;
}
.shoppage .shopfacts dl { margin: 0; display: grid; gap: var(--sp-3); }
/* Two columns as soon as there is room for two: a term and its value on one
   line is one fact, and three facts then occupy three lines instead of six. */
@media (min-width: 520px) { .shoppage .shopfacts dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.shoppage .shopfacts dl > div { min-width: 0; }
.shoppage .shopfacts dt {
  font-size: var(--fs-micro); font-weight: 700; color: var(--dim2);
}
.shoppage .shopfacts dd {
  margin: 0; font-size: var(--fs-sm); color: var(--ink); overflow-wrap: anywhere;
}
.shoppage .shopfacts dd a { color: var(--a3); }

/* The week, folded. Seven lines that nobody opened the page for, next to the
   one line they did — so the summary carries today's answer and the rest is
   one tap away. `list-style: none` + the UA's ::-webkit-details-marker both,
   or Safari keeps drawing its triangle. */
.shoppage .shophours { margin-top: var(--sp-4); border-top: 1px solid var(--line); padding-top: var(--sp-4); }
.shoppage .shopfacts > dl + .shophours,
.shoppage .shopfacts > .desc + .shophours { margin-top: var(--sp-4); }
.shoppage .shopfacts > .shophours:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
.shoppage .shophours > summary {
  display: flex; align-items: baseline; gap: var(--sp-4); cursor: pointer;
  list-style: none; padding: var(--sp-1) 0;
}
.shoppage .shophours > summary::-webkit-details-marker { display: none; }
.shoppage .shophours > summary .k { font-size: var(--fs-micro); font-weight: 700; color: var(--dim2); }
.shoppage .shophours > summary .v { font-size: var(--fs-sm); color: var(--ink); }
/* The affordance: a chevron that turns. Drawn from a border rather than a
   glyph so it inherits the text colour in both themes and never re-skins. */
.shoppage .shophours > summary::after {
  content: ""; margin-inline-start: auto; flex: 0 0 auto;
  width: 7px; height: 7px; border-right: 1.6px solid var(--dim2); border-bottom: 1.6px solid var(--dim2);
  transform: rotate(45deg) translateY(-2px); transition: transform .12s ease;
}
.shoppage .shophours[open] > summary::after { transform: rotate(-135deg) translateY(-1px); }
.shoppage .shophours .wk { display: grid; gap: 2px; padding-top: var(--sp-3); }
.shoppage .shophours .wk > div {
  display: flex; justify-content: space-between; gap: var(--sp-4);
  font-size: var(--fs-sm); color: var(--dim);
}
/* `dir="ltr"` on the element sets the direction; `isolate` stops the run from
   being reordered against the Arabic around it, which turned "10:00–22:00"
   into "22:00–10:00" on screen. */
.shoppage .shophours .wk .h { font-variant-numeric: tabular-nums; unicode-bidi: isolate; }
.shoppage .shophours .wk > .today { color: var(--ink); font-weight: 700; }

/* ---------- the dine-in door ---------- */
/* dinein.js emits `.section` + a `.btn.ghost.block`, and `.section` has no rule
   anywhere in this file — so the call to action rendered as a full-width
   outlined rectangle, i.e. exactly like an empty form field. It is an offer,
   so it is a card; the button keeps `ghost` because scanning a table code is
   not the page's primary action (ordering is), and two filled buttons on one
   screen means neither is primary. */
.shoppage .shopdine {
  margin: var(--sp-5) var(--sp-6) 0; padding: var(--sp-2) var(--sp-5) var(--sp-5);
  background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-lg);
  box-shadow: var(--card-shadow);
}
.shoppage .shopdine .section-label { padding-inline: 0; }
.shoppage .shopdine .hint { margin-bottom: var(--sp-4); }
.shoppage .shopdine .btn + .hint { margin-top: var(--sp-4); }

/* ---------- section headings + the category jump bar ---------- */
/* The count was "(3)" inside the heading, in the heading's own 13px bold, so it
   read as part of the section's name. Its own weight, its own rung. */
.shoppage .section-label .n {
  font-weight: 600; color: var(--dim2); font-size: var(--fs-micro);
  font-variant-numeric: tabular-nums;
}
/* Scroll target: the sticky app bar would otherwise land on top of the heading
   the jump bar just scrolled to. */
.shoppage .section-label[id] { scroll-margin-top: calc(var(--appbar-h) + var(--colhead-h) + var(--sp-4)); }

.shoppage .catnav {
  display: flex; gap: var(--sp-3); overflow-x: auto; scrollbar-width: none;
  padding: var(--sp-4) var(--sp-6) var(--sp-1);
  /* The bar itself scrolls sideways; the page must never. */
  overscroll-behavior-x: contain;
}
.shoppage .catnav::-webkit-scrollbar { display: none; }
.shoppage .catnav .chip { flex: 0 0 auto; white-space: nowrap; cursor: pointer; }
.shoppage .catnav .chip:hover { background: var(--line); color: var(--ink); }
.shoppage .catnav .chip .n { color: var(--dim2); font-weight: 600; font-variant-numeric: tabular-nums; }

/* ---------- the menu grid ---------- */
/* Two columns even at 360px, and three from 520px — the same steps `.pgrid`
   takes for retail products, deliberately: a customer scanning a menu and a
   customer scanning a product list are doing the same thing, and two grids
   with different rhythms make one product look like two. */
.shoppage .mgrid {
  display: grid; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6);
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 520px) { .shoppage .mgrid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

.shoppage .mcard {
  display: flex; flex-direction: column; min-width: 0;
  background: var(--s1); border: 1px solid var(--line); border-radius: var(--r-md);
  overflow: hidden; box-shadow: var(--card-shadow);
  transition: border-color .12s ease;
}
.shoppage .mcard:hover { border-color: color-mix(in srgb, var(--a2) 34%, var(--line)); }

.shoppage .mcard .shot {
  position: relative; aspect-ratio: 4 / 3; background: var(--s3);
  display: grid; place-items: center; font-size: 26px;
}
.shoppage .mcard .shot img { width: 100%; height: 100%; object-fit: cover; }

/* "20+" is a numeral — nothing to translate, and every locale reads it. It is
   NOT a discount badge and must never be styled like one; the shop's alcohol
   items carry no sale badge and no struck price anywhere (มาตรา 32, settled in
   effectivePrice()). Neutral ink on a scrim, not a status colour. */
.shoppage .mcard .shot .age {
  position: absolute; inset-inline-start: var(--sp-3); inset-block-start: var(--sp-3);
  padding: 2px var(--sp-3); border-radius: var(--r-xs);
  background: rgba(4,4,10,.66); color: #fff;
  font-size: var(--fs-micro); font-weight: 800; letter-spacing: .02em;
  unicode-bidi: isolate;   /* or Arabic renders "20+" as "+20" */
}
/* Sold out covers the photo instead of sitting beside the price: the state has
   to arrive before the tap, not at the same moment as the thing it cancels. */
.shoppage .mcard .shot .cover-badge {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: rgba(4,4,10,.62); color: #fff;
  font-size: var(--fs-sm); font-weight: 700; text-align: center; padding: var(--sp-3);
}
.shoppage .mcard.out .mbody { opacity: .62; }

.shoppage .mcard .mbody {
  display: flex; flex-direction: column; gap: var(--sp-2);
  padding: var(--sp-4); flex: 1;
}
.shoppage .mcard .nm {
  margin: 0; font-size: var(--fs-row); font-weight: 600; line-height: 1.3;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
  overflow-wrap: anywhere;
}
.shoppage .mcard .sub {
  margin: 0; font-size: var(--fs-micro); color: var(--dim2); line-height: 1.4;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
  overflow-wrap: anywhere;
}
.shoppage .mcard .sub.opt { color: var(--dim); }
/* "delivery is charged at the full price" is a correction to the price two
   lines below it, so it has to be read, not skimmed past in --dim2. */
.shoppage .mcard .sub.note { color: var(--amber); -webkit-line-clamp: 3; }

.shoppage .mcard .mfoot {
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
  margin-top: auto; padding-top: var(--sp-3); flex-wrap: wrap;
}
/* The price is what the card is for. It was --fs-micro in a `.meta` slot. */
.shoppage .mcard .pr { font-size: var(--fs-sm); color: var(--dim); min-width: 0; }
.shoppage .mcard .pr b { font-size: var(--fs-row); font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; }
.shoppage .mcard .mfoot .btn { flex: 0 0 auto; }

/* A booking row (บริการ / แพ็กเกจ) has no photo, so it stays a row — but the
   price stops being --fs-micro grey, because it is the number being decided on. */
.shoppage .row.bookrow .meta.amt { font-size: var(--fs-row); font-weight: 700; color: var(--ink); }

/* The buffet package card: the "ดูเมนูในแพ็กนี้" toggle sits under the terms
   line, and its expanded list needs breathing room above it — reusing `.list`
   as-is would butt the first row against the button. */
.shoppage .buffetpkg .linkbtn { display: inline-block; margin-top: var(--sp-3); }
.shoppage .buffetpkg .pkgmenu { margin-top: var(--sp-3); }
.shoppage .buffetpkg .pkgmenu .list { padding: 0; gap: var(--sp-3); }

/* ---------- the order bar ---------- */
/* `.buybar` carries no side padding of its own (it is used inside a sheet that
   supplies it), so on this page it sat flush against the column edge. */
.shoppage .shopbuy { padding-inline: var(--sp-6); }

/*   ▲▲▲ END — business page. Nothing below this line belongs to book.js.
   ========================================================================== */

/* ---------- product detail ---------- */
.pdetail { display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-4) 16px 0; }
.pdetail .gallery { display: flex; gap: 6px; overflow-x: auto; scroll-snap-type: x mandatory; }
.pdetail .gallery img, .pdetail .gallery .shot {
  flex: 0 0 auto; width: min(78%, 300px); aspect-ratio: 1 / 1; border-radius: var(--r-md);
  object-fit: cover; background: var(--s3); scroll-snap-align: start;
  display: grid; place-items: center; font-size: 32px;
}
.pdetail h2 { margin: 0; font-size: var(--fs-h); overflow-wrap: anywhere; }
.pdetail .price { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.pdetail .price b { font-size: var(--fs-h); color: var(--a1); }
.pdetail .price s { color: var(--dim2); }
.pdetail .desc { margin: 0; font-size: var(--fs-row); color: var(--dim); white-space: pre-wrap; overflow-wrap: anywhere; }
.pdetail .l2 { font-size: var(--fs-sm); color: var(--dim); overflow-wrap: anywhere; }
.pdetail .label { display: flex; flex-direction: column; gap: 4px; }

.buybar { display: flex; gap: 8px; align-items: center; position: sticky; bottom: 0;
  padding: var(--sp-4) 0 calc(var(--tabbar-h) + 12px); background: var(--bg); }
@media (min-width: 640px) { .buybar { padding-bottom: 12px; } }
/* completed to match `.u-grow`: `flex: 1` alone still carries
   `min-width: auto`, so this column grew but refused to shrink. */
.buybar .grow { flex: 1; min-width: 0; }

.qtybox { display: flex; align-items: center; gap: 2px; flex: 0 0 auto;
  border: 1px solid var(--line); border-radius: var(--r-xl); padding: 2px; background: var(--s1); }
.qtybox button {
  width: 32px; height: 32px; border: 0; background: transparent; color: var(--dim);
  border-radius: var(--r-pill); cursor: pointer; font-size: var(--fs-title); line-height: 1;
}
.qtybox button:hover { background: var(--s3); color: var(--ink); }
.qtybox input {
  width: 42px; border: 0; background: transparent; text-align: center; font-size: var(--fs-row);
  -moz-appearance: textfield; appearance: textfield;
}
.qtybox input::-webkit-outer-spin-button, .qtybox input::-webkit-inner-spin-button { display: none; }
.qtybox.sm button { width: 26px; height: 26px; font-size: var(--fs-body); }
.qtybox.sm span { min-width: 22px; text-align: center; font-size: var(--fs-base); font-variant-numeric: tabular-nums; }

/* ---------- cart / checkout ---------- */
.checkout { display: flex; flex-direction: column; gap: 6px; }
.checkout .totals, .checkout .editor { padding: 8px 16px; }
.cartline .thumb {
  width: 40px; height: 40px; flex: 0 0 auto; border-radius: var(--r-sm); overflow: hidden;
  background: var(--s3); display: grid; place-items: center; font-size: 16px;
}
.cartline .thumb img { width: 100%; height: 100%; object-fit: cover; }
.checkout .segs { margin-inline: 0; }

/* ---------- order progress ---------- */
.orderview { padding: var(--sp-4) 16px 0; }
.steps { list-style: none; margin: 4px 0; padding: 0; display: flex; gap: 4px; }
.steps li { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 3px; }
.steps .dotstep { width: 100%; height: 4px; border-radius: var(--r-pill); background: var(--s3); }
.steps li.on .dotstep { background: var(--green); }
.steps .lbl { font-size: var(--fs-micro); color: var(--dim2); text-align: center; line-height: 1.25; }
.steps li.on .lbl { color: var(--dim); }

/* ---------- the "เพิ่มเติม" hub ---------- */
.row .emoji {
  width: 40px; height: 40px; flex: 0 0 auto; border-radius: var(--r-md); background: var(--s3);
  display: grid; place-items: center; font-size: 20px;
}
.row.appmore { opacity: .82; }
.row.appmore .l2 { white-space: normal; line-height: 1.4; }
.railcard.promo { margin: 12px 16px; }

/* ==========================================================================
   Notification bell panel

   The bell opens the list where the reader is, instead of moving them to
   another screen. It is a dropdown from ~720px up and a full-width sheet under
   the app bar below that — a 380px panel pinned to the corner of a 360px phone
   has no margin on one side and wastes the width the sentence needs.

   `max-height` is set in JS from the anchor's position, so the panel always
   fits the viewport and scrolls inside itself; the page behind it never
   scrolls sideways and the footer is never out of reach.
   ========================================================================== */
.notifpanel {
  position: fixed; z-index: 82; display: flex; flex-direction: column; min-height: 0;
  background: var(--s1); border: 1px solid var(--line);
  border-radius: var(--r-lg); box-shadow: var(--shadow); overflow: hidden;
}
/* The panel takes focus on open. It resets nothing, so the global focus rule
   still applies — this only makes the ring hug the rounded corner. */
.notifpanel:focus-visible { outline: 2px solid var(--a2); outline-offset: 2px; }

.np-head {
  display: flex; align-items: center; gap: 8px;
  padding: var(--sp-4) 12px; border-bottom: 1px solid var(--line); flex: 0 0 auto;
}
.np-head .np-title { flex: 1; min-width: 0; font-size: var(--fs-body); font-weight: 700;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.np-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.np-body .list { padding: 8px; gap: 6px; }
/* An empty/failed box inside a 380px panel is a note, not a poster. */
.np-body .state { padding: var(--sp-7) var(--sp-6); }
.np-body .state .icon { font-size: 24px; width: auto; height: auto; background: none; border: 0; }
.np-body .state .detail { max-width: 28ch; }

.np-foot { flex: 0 0 auto; border-top: 1px solid var(--line); padding: 6px; }
.np-foot .np-all {
  display: block; padding: var(--sp-4) var(--sp-4); border-radius: var(--r-md);
  font-size: var(--fs-base); font-weight: 700; color: var(--a2); text-align: center;
}
.np-foot .np-all:hover { background: var(--s2); }

/* ---------- a notification row: read vs unread ----------
   Unread is signalled three ways and never by colour alone: the grey tint, the
   sentence in bold, and a filled dot in the leading gutter. A tint on its own
   vanishes on a poor screen and for a colour-blind reader; the dot and the
   weight do not. The dot keeps its space when read (transparent), so a row
   does not shift sideways the moment it is opened. */
/* The house row density, not a local variant: `padding` comes off entirely so
   `.row`'s own `7px 10px` applies, which is CLAUDE.md's table verbatim.
   `align-items: center` because the row is now two lines of equal weight —
   `flex-start` was there to top-align a title that could run to two lines, and
   with the title back to one line it only pushed the avatar and the timestamp
   out of alignment with it. */
.row.notif { align-items: center; }
.row.notif .udot {
  width: 8px; height: 8px; border-radius: var(--r-pill);
  background: var(--a2); flex: 0 0 auto;
}
.row.notif .udot.off { background: transparent; }
/* `.row .l1` — one line, ellipsised — now applies unmodified. The two-line
   clamp that used to live here is GONE: it was not a decision about
   notifications, it was an accommodation for a title with nowhere to go,
   because `.meta` was stacking four things down the right-hand side. The width
   is back (see notifications.js), so the accommodation is not needed, and
   one-line-with-ellipsis is what makes a list scannable at all. */
.row.notif.unread { background: var(--s3); }
.row.notif.unread .l1 { font-weight: 700; }

/* The timestamp, and only the timestamp. `.row .meta`'s 46% is a ceiling for a
   column that might hold a chip; here it holds "2 น." forever, so it is pinned
   narrow and the whole remainder belongs to the title. */
.row.notif .meta { max-width: none; white-space: nowrap; }

/* The secondary line carries the kind chip and the preview text on ONE line:
   `คำสั่งซื้อใหม่ · ฿530`. The chip is fixed-size, the text takes the rest and
   ellipsises — so a long preview shortens itself instead of pushing the chip
   onto a line of its own, which is the same failure one level down. */
.row.notif .l2 {
  display: flex; align-items: center; gap: var(--sp-3);
  white-space: normal; overflow: visible;
}
.row.notif .l2 .ntext {
  min-width: 0; flex: 1 1 auto;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* A chip on the secondary line is a label, not a badge to be read at distance:
   it sits at the row's secondary size so it cannot out-shout the title above
   it, and it never shrinks below its own text. */
.row.notif .l2 .chip { flex: 0 0 auto; padding: 1px var(--sp-3); font-size: var(--fs-micro); }

/* Text only a screen reader gets — the tint, the dot and the weight all say
   "unread" to the eye and nothing at all to a screen reader. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* ==========================================================================
   Install / update bar  (install.js)

   A BAR, never a modal. Google penalises an intrusive interstitial on mobile,
   and the whole point of the public pages is to be found in search — so this
   one sits above the tab bar, pushes the content up rather than covering it,
   and carries its own close button. It is only ever mounted inside the web
   app; /b/, /p/, /u/, /g/ and /dine never load install.js.
   ========================================================================== */
.installbar {
  position: fixed; z-index: 50;
  inset-inline: 8px;
  bottom: calc(var(--tabbar-h) + 8px + env(safe-area-inset-bottom, 0px));
  display: flex; align-items: center; gap: var(--sp-4);
  padding: var(--sp-4) var(--sp-4) var(--sp-4) 12px;
  border: 1px solid var(--line); border-radius: var(--r-lg);
  background: color-mix(in srgb, var(--s2) 96%, transparent);
  backdrop-filter: blur(14px);
  box-shadow: var(--shadow);
  animation: ib-in .22s ease-out;
}
@keyframes ib-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .installbar { animation: none; } }

.installbar .ib-icon { flex: 0 0 auto; line-height: 0; }
.installbar .ib-icon img { width: 36px; height: 36px; border-radius: var(--r-md); }

.installbar .ib-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.installbar .ib-title {
  font-size: var(--fs-row); font-weight: 600; color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The iOS instruction names a button the reader has to find, so it is allowed
   to wrap onto a second line — truncating it would hide the instruction. */
.installbar .ib-body {
  font-size: var(--fs-sm); color: var(--dim); line-height: 1.35;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.installbar .ib-share {
  display: inline-block; vertical-align: -3px; margin-inline-end: 3px;
}
.installbar .ib-share svg { width: 14px; height: 14px; display: inline-block; }

.installbar .ib-action {
  flex: 0 0 auto; border: 0; cursor: pointer;
  padding: 8px var(--sp-5); border-radius: var(--r-btn);
  background: var(--brand); color: var(--on-accent);
  font-size: var(--fs-base); font-weight: 700; white-space: nowrap;
}
.installbar .ib-action:hover { filter: brightness(1.08); }

.installbar .ib-close {
  flex: 0 0 auto; border: 0; background: transparent; color: var(--dim2);
  cursor: pointer; padding: 6px; border-radius: var(--r-pill); line-height: 0;
}
.installbar .ib-close:hover { background: var(--s3); color: var(--ink); }
.installbar .ib-close svg { width: 16px; height: 16px; }

/* Make room, rather than covering the last row of the list. */
body[data-installbar="1"] .view { padding-bottom: calc(var(--tabbar-h) + var(--sp-7) + 72px); }
@media (min-width: 640px) {
  body[data-installbar="1"] .view { padding-bottom: 100px; }
  /* On a wide screen the bar has no reason to span the window. */
  .installbar { inset-inline: auto 16px; max-width: 420px; }
}
@media (min-width: 1024px) {
  /* The dock owns the bottom-right corner; the bar steps aside. */
  .installbar { inset-inline: 16px auto; }
  body[data-installbar="1"] .view { padding-bottom: 120px; }
}
/* The chat room measures its own height from the chrome, so it needs telling. */
body[data-installbar="1"] .chatroom { height: calc(100dvh - var(--appbar-h) - var(--colhead-h) - var(--tabbar-h) - 72px); }

/* ---------- carriers, buffet role, alcohol ----------------------------------
   A segmented radio group is spelled `aria-checked` in these screens (the
   correct ARIA for role="radio"; the older ones use aria-selected). Both
   selectors are listed so one convention cannot silently lose its highlight. */
.segs button[aria-checked="true"] { background: var(--a2); color: #fff; }
/* A four-way choice does not fit one row at 360px, and a delivery mode is a
   sentence rather than a word. */
.segs.col { flex-direction: column; gap: 3px; margin-inline: 0; }
.segs.col button { justify-content: flex-start; text-align: start; }
/* `.grow` is scoped per component in this stylesheet, so a segment that carries
   a label AND a price has to be told how to share the row. */
.segs.col button .grow { flex: 1; min-width: 0; text-align: start; }
.segs.col button .amt { flex: 0 0 auto; font-variant-numeric: tabular-nums; }

/* The alcohol switch carries a second line saying what it turns on, so it
   cannot centre its two lines against the box. */
.check.alcohol { align-items: flex-start; }
.check.alcohol > span { display: flex; flex-direction: column; gap: 2px; }
.check.alcohol .l1 { color: var(--ink); font-weight: 600; }
.check.alcohol .l2 { font-size: var(--fs-sm); color: var(--dim2); }

.carrierrow { gap: var(--sp-4); flex-wrap: wrap; }
.carrierrow .check { flex: 1 1 40%; }
.carrierrow .label.fee { flex: 0 0 auto; margin: 0; }
.carrierrow input.field.fee { width: 96px; text-align: end; font-variant-numeric: tabular-nums; }

/* The tracking row: a link when the carrier has a tracking page, the number and
   the reason when it has none — never a dead link. */
.facts .fact.track { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.facts .fact.track .tk-label { color: var(--dim); font-weight: 600; }
.facts .fact.track .tk-no { color: var(--a1); font-weight: 700; overflow-wrap: anywhere; }
.facts .fact.track a.tk-no { text-decoration: underline; }
.facts .fact.track .tk-copy { padding-block: 2px; }
.facts .fact.track .tk-note { flex-basis: 100%; color: var(--dim2); font-size: var(--fs-micro); font-weight: 400; }

/* ---------- สมุดที่อยู่ ------------------------------------------------------
   The picker reuses `.segs.col` so a saved address is the same radio control as
   a carrier or a delivery mode — one modal look, one selection look. */
.addrpick-head { display: flex; align-items: center; gap: 8px; }
.addrpick-head .section-label { padding-top: 0; }
.segs.col .addropt { align-items: flex-start; padding-block: var(--sp-3); }
.segs.col .addropt .grow { display: flex; flex-direction: column; gap: 2px; }
.segs.col .addropt .l1 { font-weight: 700; }
.segs.col .addropt .l2 { font-size: var(--fs-sm); font-weight: 400; opacity: .85; overflow-wrap: anywhere; }
/* The selected row goes solid accent, so its secondary lines must not stay dim
   against it — they would drop below contrast on the accent fill. */
.segs.col .addropt[aria-checked="true"] .l2 { opacity: .95; }

.addracts { display: flex; gap: 8px; flex-wrap: wrap; padding-top: 6px; }
.addracts .btn { padding-block: 4px; }
.addracts .btn.danger { color: var(--red); }

.addrrow .l1 { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.addrrow .l2 { overflow-wrap: anywhere; }

/* The chosen address, spelled out under the picker before paying. */
.addrchosen { border: 1px solid var(--line); border-radius: var(--r-md); padding: 10px 12px; }
.addrchosen .l1 { font-weight: 700; font-size: var(--fs-row); }
.addrchosen .l2 { font-size: var(--fs-sm); color: var(--dim); overflow-wrap: anywhere; }

/* A long structured form in a sheet: the cascade selects are full-width rows,
   and two-up pairs collapse on a narrow phone like every other .pair. */
.addrform .check { align-items: flex-start; }
.addrform .check > span { display: flex; flex-direction: column; gap: 2px; }
.addrform .check .l1 { color: var(--ink); font-weight: 600; }
.addrform .check .l2 { font-size: var(--fs-sm); color: var(--dim2); }

/* ---------------------------------------------------------------------------
   โรงงาน & สต๊อก (factory.js)

   Status colours here are LITERAL by way of the status tokens (--amber/--red/
   --green), never accent-derived: "ติดลบ — ขายเกินสต๊อก" must mean the same red
   whatever accent the user picked, exactly as CLAUDE.md's palette requires.

   Nothing below resets `outline`, so the app-wide `:where()` focus ring still
   applies — and the two rows that are clickable without being a <button> get an
   explicit ring anyway, because a keyboard user must see where they are.
--------------------------------------------------------------------------- */

.fac-chips { display: flex; gap: 6px; flex-wrap: wrap; padding: 0 16px 8px; }

.fac-row { cursor: pointer; }
.fac-row:focus-visible { outline: 2px solid var(--a2); outline-offset: 2px; border-radius: var(--r-md); }

/* Provenance. Amber past 30 days is not an error — it is a statement about how
   much the number above it should be trusted. */
.fac-prov { font-size: var(--fs-sm); color: var(--dim2); }
.fac-prov.stale { color: var(--amber); }

.fac-dim2 { color: var(--dim2); }

.fac-level { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex: 0 0 auto; }
.fac-level b { font-size: var(--fs-row); font-weight: 700; font-variant-numeric: tabular-nums; }
.fac-level.warn b { color: var(--amber); }
.fac-level.bad  b { color: var(--red); }

/* A cost line renders only when there is a cost to render — see costCell(). */
.fac-cost { font-size: var(--fs-sm); color: var(--dim); font-variant-numeric: tabular-nums; }

.fac-move { align-items: flex-start; }
.fac-qty {
  font-variant-numeric: tabular-nums; font-weight: 700; font-size: var(--fs-sm);
  flex: 0 0 auto; white-space: nowrap;
}
.fac-qty.green { color: var(--green); }
.fac-qty.red   { color: var(--red); }
.fac-qty.amber { color: var(--amber); }
.fac-qty.warn  { color: var(--amber); }
.fac-qty.bad   { color: var(--red); }

.fac-head {
  display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap;
  padding: 4px 16px 8px;
}
.fac-title { flex: 1 1 240px; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.fac-title .l1 { font-weight: 700; }

/* A recipe line and a completion line are the same shape: a name, a number the
   user types, and the variance beside it. They wrap on a narrow phone rather
   than pushing the input off the edge. */
.fac-bomline, .fac-countline { flex-wrap: wrap; align-items: flex-end; gap: 8px; }
.fac-bomline .label, .fac-countline .label { flex: 0 1 130px; }
.fac-bomline .label.grow { flex: 1 1 180px; }

.fac-saved { font-size: var(--fs-sm); color: var(--dim2); flex: 0 0 auto; }
.fac-saved.good { color: var(--green); }
.fac-saved.bad  { color: var(--red); }

.fac-filters { gap: 8px; }

/* ==========================================================================
   ระบบอัตโนมัติ — the Workflow Automation console tab (automation.js).

   Nothing new is invented: the health dot, the run dot and every chip use the
   literal status palette declared at the top of this file, because a run that
   says "ผิดพลาด" must be the same red as everything else that failed. The one
   genuinely new control is the on/off switch, which the rest of the app has
   never needed — every other toggle here is a checkbox in a row.
   ========================================================================== */

/* ---------- health strip ---------- */
.healthstrip {
  margin: var(--sp-4) 16px 0; padding: var(--sp-4) var(--sp-5);
  background: var(--s2); border-radius: var(--r-md);
  display: flex; flex-direction: column; gap: 2px;
}
.healthstrip .hline { display: flex; align-items: center; gap: var(--sp-4); font-size: var(--fs-sm); }
.healthstrip .hline .grow { flex: 1; min-width: 0; }
.healthstrip .hsub { font-size: var(--fs-micro); color: var(--dim2); overflow-wrap: anywhere; }
.healthstrip .hsub.bad { color: var(--red); }
.healthstrip .budget { flex: 0 0 auto; font-variant-numeric: tabular-nums; color: var(--dim); }
.healthstrip .budget.amber { color: var(--amber); font-weight: 700; }
.healthstrip .budget.red { color: var(--red); font-weight: 700; }

/* The status dot, shared by the health strip and every run row. `dim` is a
   real value here: "did not match" is not a warning, it is the normal case. */
.dot { width: 8px; height: 8px; border-radius: var(--r-pill); flex: 0 0 auto; background: var(--dim2); }
.dot.green  { background: var(--green); }
.dot.amber  { background: var(--amber); }
.dot.red    { background: var(--red); }
.dot.blue   { background: var(--blue); }
.dot.sky    { background: var(--sky); }
.dot.dim    { background: var(--dim2); }

/* ---------- the on/off switch on a rule card ---------- */
/* `position: relative` is load-bearing, not decoration: the checkbox below is
   absolutely positioned, and without it the input escapes to the nearest
   positioned ancestor and the hit area stops covering the switch. */
.autoswitch { position: relative; display: inline-flex; align-items: center; flex: 0 0 auto; cursor: pointer; }
.autoswitch.off { cursor: not-allowed; opacity: .55; }
/* The checkbox stays in the accessibility tree and keeps the focus ring: it is
   moved behind the track, never `display:none`, or the control becomes
   unreachable by keyboard and unnamed to a screen reader. */
.autoswitch input { position: absolute; opacity: 0; width: 40px; height: 22px; margin: 0; cursor: inherit; }
.autoswitch .track {
  width: 40px; height: 22px; border-radius: var(--r-pill); background: var(--s3);
  border: 1px solid var(--line); position: relative; transition: background .12s ease;
}
.autoswitch .knob {
  position: absolute; inset-block-start: 2px; inset-inline-start: 2px;
  width: 16px; height: 16px; border-radius: var(--r-pill); background: var(--dim);
  transition: inset-inline-start .12s ease, background .12s ease;
}
.autoswitch input:checked + .track { background: color-mix(in srgb, var(--a2) 55%, transparent); border-color: var(--a2); }
.autoswitch input:checked + .track .knob { inset-inline-start: 20px; background: #fff; }
.autoswitch input:focus-visible + .track { outline: 2px solid var(--a2); outline-offset: 2px; }

/* ---------- rule card ---------- */
.rulecard { cursor: pointer; }
.rulecard .l2 { white-space: normal; }
.rulecard .chips { display: flex; gap: 6px; flex-wrap: wrap; }
.rulecard .chips:empty { display: none; }
.rulecard .runline { font-size: var(--fs-micro); color: var(--dim2); overflow-wrap: anywhere; }

/* Paused is a band, not a chip: it carries the shop's next action, and it has
   to survive being read at a glance from the list. */
.pausedband {
  display: flex; align-items: center; gap: var(--sp-4);
  background: color-mix(in srgb, var(--red) 14%, transparent);
  border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4);
}
.pausedband .why { flex: 1; min-width: 0; font-size: var(--fs-sm); color: var(--red); overflow-wrap: anywhere; }

/* ---------- run log ---------- */
.chiprow { display: flex; gap: 6px; flex-wrap: wrap; padding: var(--sp-4) 16px 0; }
/* `button.chip.pick` caps itself at 40% for the feed composer's series chip;
   a filter row and a placeholder row need the whole label. */
.chiprow button.chip.pick { max-width: none; }

.row.runrow { text-align: start; font: inherit; color: var(--ink); width: 100%; cursor: pointer; align-items: flex-start; }
.row.runrow .when {
  font-size: var(--fs-micro); color: var(--dim2); flex: 0 0 auto;
  font-variant-numeric: tabular-nums; padding-top: 1px;
}
.row.runrow .dot { margin-top: 6px; }
.row.runrow .l1, .row.runrow .l2 { display: block; }
.row.runrow .l2 { white-space: normal; }

/* ---------- run detail ---------- */
.condline {
  display: flex; align-items: flex-start; gap: var(--sp-4);
  padding: var(--sp-3) 0; border-top: 1px solid var(--line);
}
.condline:first-child { border-top: 0; }
.condline .mark { flex: 0 0 auto; font-weight: 700; }
.condline.ok .mark { color: var(--green); }
.condline.no .mark { color: var(--red); }
.condline .grow { flex: 1; min-width: 0; }
.condline .l1 { display: block; font-size: var(--fs-base); overflow-wrap: anywhere; }
.condline .l2 { display: block; font-size: var(--fs-micro); color: var(--dim2); overflow-wrap: anywhere; }

/* ---------- a sub-view inside a console section (editor / run detail) ---------- */
.subview { display: flex; flex-direction: column; gap: var(--sp-4); }
.subhead { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) 16px 0; }
.subhead h3 { margin: 0; font-size: var(--fs-title); font-weight: 700; }
.subhead .grow { flex: 1; min-width: 0; }
.automation .editor { padding: 0 16px var(--sp-6); }

/* ---------- rule editor ---------- */
.row.pickrow { text-align: start; font: inherit; color: var(--ink); width: 100%; cursor: pointer; }
.row.pickrow .l1, .row.pickrow .l2 { display: block; }
.row.pickrow .l2 { white-space: normal; }
.row.pickrow .chev { color: var(--dim2); flex: 0 0 auto; }
.row.pickrow .tick { color: var(--green); font-weight: 700; flex: 0 0 auto; }
/* Unavailable is GREYED WITH ITS REASON, never hidden — a missing option is
   indistinguishable from one that never existed. */
.row.pickrow:disabled { cursor: not-allowed; opacity: .5; }
.row.pickrow:disabled .l2 { color: var(--amber); }

.condwrap { display: flex; flex-direction: column; gap: 2px; }
.row.condrow { flex-wrap: wrap; gap: 6px; }
.row.condrow .grow { flex: 1 1 140px; min-width: 0; }
.row.condrow select.chip { flex: 0 0 auto; max-width: 45%; }
.twoval { display: flex; align-items: center; gap: 6px; }
.twoval .field { min-width: 0; flex: 1 1 70px; }
.twoval .dash { color: var(--dim2); flex: 0 0 auto; }
.multi { display: flex; gap: 8px; flex-wrap: wrap; }
.unitbox { display: flex; align-items: center; gap: 4px; }
.unitbox .field { min-width: 0; flex: 1; }
.unitbox .unit { color: var(--dim2); font-size: var(--fs-sm); flex: 0 0 auto; }

/* The backtest answer, pinned above the arm button and always visible: it is
   the only thing on the screen that says what arming this rule will actually
   do to real customers. */
.backtest {
  border: 1px dashed var(--line); border-radius: var(--r-md);
  padding: var(--sp-4) var(--sp-5); font-size: var(--fs-sm); color: var(--dim);
  display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap;
}
.backtest.warn { border-color: var(--amber); color: var(--amber); }
.backtest.bad { border-color: var(--red); color: var(--red); }

/* ---------- ตอบอัตโนมัติ, on a chat bubble, on both sides ---------- */
.msg .bubble .autochip {
  display: inline-block; margin-bottom: 3px; padding: 1px 6px;
  border-radius: var(--r-sm); background: var(--s3); color: var(--dim);
  font-size: var(--fs-micro); line-height: 1.5;
}
/* On my own (gradient) bubble the surface token disappears into the fill, so
   the chip takes a translucent white instead — same chip, still legible. */
.msg.mine .bubble .autochip {
  background: rgba(255, 255, 255, .22); color: #fff;
}

/* ==========================================================================
   เรซูเม่ / โปรไฟล์ผู้หางาน
   ==========================================================================
   Two surfaces from one stylesheet: the editor (#/resume), which lives inside
   the app shell and reuses the existing ladder, and the export page (#/r/<token>),
   which is going onto PAPER. The print rules are the point of the second one —
   we deliberately do not generate a PDF ourselves, because no runtime we control
   carries a Thai font and the file would come out as empty boxes. The browser
   already renders Thai correctly and every platform's print dialog offers
   "Save as PDF", so the OS makes the file and we make the layout. */

.card {
  background: var(--s1);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 10px 12px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-block: 6px;
}
.card.row { flex-direction: row; align-items: center; gap: 8px; }
/* Amber = waiting on someone. Used by the "someone is waiting for your answer"
   row, which is the only thing on this screen that is about another person. */
.card.row.warn { border-color: color-mix(in srgb, var(--amber) 45%, var(--line)); }

/* A whole-card click target gets NO ripple/flash — a full-card highlight on
   every tap reads as a glitch. It gets a border cue on hover instead. */
.tappable { cursor: pointer; }
.card.tappable:hover { border-color: color-mix(in srgb, var(--a2) 40%, var(--line)); }
.row.tappable {
  width: 100%; text-align: start; background: none; border: 0;
  color: inherit; font: inherit; padding: 8px 4px; cursor: pointer;
}

/* A featured skill is the one the employer should read first, so it is filled
   rather than merely bolder — weight alone does not survive a grayscale print. */
.chip.on {
  background: color-mix(in srgb, var(--a2) 18%, transparent);
  color: var(--a2);
  border-color: color-mix(in srgb, var(--a2) 45%, var(--line));
}

/* The visibility / share toggles. */
.switch { position: relative; display: inline-block; width: 44px; height: 24px; flex: none; }
.switch input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
.switch span {
  position: absolute; inset: 0; border-radius: 999px;
  background: var(--s3); border: 1px solid var(--line); transition: background .15s;
  pointer-events: none;
}
.switch span::after {
  content: ""; position: absolute; top: 2px; inset-inline-start: 2px;
  width: 18px; height: 18px; border-radius: 50%; background: var(--dim);
  transition: transform .15s, background .15s;
}
.switch input:checked + span {
  background: color-mix(in srgb, var(--green) 35%, transparent);
  border-color: var(--green);
}
.switch input:checked + span::after { transform: translateX(20px); background: var(--green); }
html[dir="rtl"] .switch input:checked + span::after { transform: translateX(-20px); }
/* Every reset of the outline must restore one in the same rule — a keyboard
   user with no visible ring on a privacy switch is the worst place to lose it. */
.switch input:focus-visible + span { outline: 2px solid var(--a2); outline-offset: 2px; }

/* The "what this switch actually covers" panel. Sky = informational, never a
   status colour that means something else elsewhere. */
.note.info {
  background: color-mix(in srgb, var(--sky) 10%, transparent);
  border: 1px solid color-mix(in srgb, var(--sky) 30%, transparent);
  border-radius: 12px; padding: 10px 12px;
  display: flex; flex-direction: column; gap: 3px;
  font-size: .82rem; color: var(--dim);
  margin-block: 8px;
}
.note.info .note-title { color: var(--sky); font-weight: 700; }

.section-label.row { display: flex; align-items: center; gap: 8px; }

/* -------------------------------------------------------------------------
   The export page
   ------------------------------------------------------------------------- */
.resume-doc { max-width: 760px; margin-inline: auto; padding-inline: 16px; }
.resume-head { border-bottom: 2px solid var(--line); padding-bottom: 10px; margin-bottom: 14px; }
.resume-head h1 { margin: 0 0 2px; font-size: 1.6rem; line-height: 1.25; }
.resume-headline { margin: 0 0 4px; font-weight: 600; color: var(--a2); }
.resume-doc section { margin-block: 14px; }
.resume-doc h2 {
  font-size: .82rem; text-transform: uppercase; letter-spacing: .06em;
  color: var(--dim); margin: 0 0 6px; padding-bottom: 3px;
  border-bottom: 1px solid var(--line);
}
.resume-entry { margin-bottom: 8px; display: flex; flex-direction: column; gap: 2px; }
.resume-entry strong { font-weight: 600; }
.resume-actions { display: flex; justify-content: flex-end; padding-block: 10px; }
.resume-foot { margin-top: 20px; padding-top: 8px; border-top: 1px solid var(--line); text-align: center; }

/* The standalone export page carries no app chrome: a screenshot of our
   navigation rail is not a résumé. */
html.resume-print .rail,
html.resume-print .dock,
html.resume-print .appbar,
html.resume-print .aside { display: none !important; }

@media print {
  /* Force the light palette. A dark résumé prints as a solid black rectangle,
     which is both unreadable and an unwelcome surprise at someone's printer. */
  :root, html[data-theme="dark"] {
    --bg: #fff; --s1: #fff; --s2: #fff; --s3: #fff; --line: #ccc;
    --ink: #000; --dim: #444; --dim2: #666; --a2: #000;
  }
  html, body { background: #fff !important; color: #000 !important; }
  .no-print, .rail, .dock, .appbar, .aside, .banner, .state.loading { display: none !important; }
  .resume-doc { max-width: none; padding: 0; }
  .card, .note.info { border: 0; padding: 0; margin: 0; background: none; }
  .chip {
    border: 1px solid #999 !important; background: none !important;
    color: #000 !important;
  }
  /* Keep an entry off a page boundary: a job title on one page and its dates on
     the next reads as two jobs. */
  .resume-entry, .resume-head { break-inside: avoid; }
  .resume-doc section { break-inside: avoid-page; }
  a { color: #000; text-decoration: none; }
  @page { margin: 16mm; }
}

/* ==========================================================================
   FORM CONTROLS  ·  begin
   --------------------------------------------------------------------------
   Owned by the forms pass (2026-07-28). Inputs, textareas, labels, field
   groups, the image picker, and the save bar at the foot of a form. Anything
   about the app shell, the layout grid or a business page belongs elsewhere in
   this file — keep additions between the two markers.

   The complaint that produced it: "หน้าโปรไฟล์ข้องกรอกก็ไม่สวย". Three real
   causes underneath, none of them decoration:
     1. `<input type="file">` was raw. It is the one control a stylesheet cannot
        reach through its own element, so it painted the browser's grey button
        in the browser's font — as the FIRST field on the screen.
     2. Every field was full-width and every gap was the same, so nothing on the
        form read as grouped, and a 590px-wide box for a 12-character nickname
        is harder to aim at than a narrow one, not easier.
     3. In the light theme `--s2` (a field) and `--s1` (a card) are BOTH #FFFFFF
        — deliberately, see the token block — so a field sitting on a card had
        only its hairline to say it was a field. Fields drop one rung to `--s3`
        in light; in dark they stay on `--s2`, which already lifts off `--s1`.
   ========================================================================== */

/* ---------- the field itself ---------- */
/* A control has to be aimable, and stating the size stops a bare <input> and a
   <select> in the same row from being two different heights. 40px is that size:
   it is the global search field's height to within a pixel and 2px under the
   chat input's, so a form field now reads as the same object as the two
   controls the user meets most often. It is still a 40px target — larger than
   the 30px `.btn.sm` this app already ships in every list row.

   The 96px bio box comes down to 72px, which is three lines of Thai at 1.5 and
   the height the box actually opens at before it grows. 96px was a quarter of a
   phone screen of empty field under a one-line placeholder. */
.field { min-height: 40px; font-size: var(--fs-row); }
textarea.field { min-height: 72px; }

/* Light theme: a white field on a white card is not a field. */
@media (prefers-color-scheme: light) {
  :root[data-theme="auto"] .field { background: var(--s3); }
}
:root[data-theme="light"] .field { background: var(--s3); }

/* A hint belongs to the field above it, not to the gap. */
.fieldcell > .hint { margin-top: 5px; margin-inline-start: 2px; }

/* ---------- field groups ----------
   Grouping is what makes a form readable; a <fieldset> is the element that
   already means it, so a screen reader announces the legend with every field
   inside instead of us inventing a div that only looks grouped. */
.fgroup {
  border: 0; margin: 0; padding: 0; min-width: 0;
  display: flex; flex-direction: column; gap: var(--sp-5);
}
.fgroup > legend {
  padding: 0; margin-bottom: var(--sp-2);
  font-size: var(--fs-base); font-weight: 700; color: var(--dim); letter-spacing: .01em;
}

/* Two short fields share a line from 520px up. Below that they stack, because
   two 130px boxes side by side is worse than one of each. The breakpoint is on
   the container's own width, not the viewport, so the same group is right in
   the 640px column and in a 420px dialog. */
.fieldrow { display: flex; flex-wrap: wrap; gap: var(--sp-5); }
.fieldcell { min-width: 0; }
/* Scoped to `.fieldrow`, and the reason is a bug this had: `flex: 1 1 220px` on
   a `.fieldcell` that is a direct child of the COLUMN `.fgroup` sets a 220px
   flex-basis on the wrong axis — a 220px-tall bio cell with 70px of dead space
   under the counter. A flex shorthand only means "220px wide" inside a row. */
.fieldrow > .fieldcell { flex: 1 1 220px; }

/* ---------- image picker ----------
   `.ff-pick` is a real <label> with the file input laid over it at zero
   opacity — the same construction as `.attachbtn` in the chat composer, and
   for the same reason: `display: none` + `.click()` makes the control
   unreachable by keyboard and nameless to a screen reader. */
.filefield { display: flex; align-items: flex-start; gap: var(--sp-5); flex-wrap: wrap; }
.ff-shot { flex: 0 0 auto; }
/* 56px, down from 72px. The picker row is a control, not the profile header —
   the actual portrait is already drawn at 96px directly above this form, so a
   second near-as-large copy inside the row was what made it read as "a tall
   filled box" rather than as one line of the form. 56px still shows the face. */
.ff-shot .av { width: 56px; height: 56px; font-size: 21px; border: 1px solid var(--line); }
/* A pending photo is shown, and shown as pending: it is not saved until the
   button is pressed, and a preview that looks final would say it already was. */
.ff-shot.pending .av {
  outline: 2px solid var(--a2); outline-offset: 2px; border-color: transparent;
}
.ff-body { flex: 1 1 200px; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-3); }
.ff-body .hint { margin: 0; }
.ff-pick { position: relative; overflow: hidden; align-self: flex-start; cursor: pointer; }
.ff-pick input[type="file"] {
  position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer;
}
.ff-pick:focus-within { outline: 2px solid var(--a2); outline-offset: 2px; }
.ff-body [data-chosen] { display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap; }
.ff-body [data-fname] { overflow-wrap: anywhere; min-width: 0; }

/* ---------- the save bar ----------
   Status and actions on one line, so "what state am I in" and "what can I do
   about it" are read together. The status line is never empty: pristine,
   dirty, saving, saved and failed are five different sentences, and silence
   would be the same bug as drawing a failed load as an empty state. */
.formbar {
  display: flex; align-items: center; gap: var(--sp-5); flex-wrap: wrap;
  padding-top: var(--sp-4); border-top: 1px solid var(--line);
}
.formstatus { flex: 1 1 180px; min-width: 0; margin: 0; overflow-wrap: anywhere; }
.formacts { display: flex; gap: var(--sp-4); flex-wrap: wrap; margin-inline-start: auto; }
/* The actions keep their natural width on a phone. They used to be
   `flex: 1 1 140px`, which stretched Save into a full-width pill — and the
   owner named exactly that. A button's width is a claim about how much there is
   to press; a six-character word inside a 358px pill is a bigger claim than the
   action deserves, and nothing else in this app stretches (`.btn.block` is the
   opt-in for the auth screens, where the button really is the whole page). The
   bar stays full width so the pair still sits against the right edge, under the
   status line rather than beside it. */
@media (max-width: 519px) {
  .formacts { width: 100%; justify-content: flex-end; }
}

/* ---------- the form as an object ---------- */
/* A text box does not get easier to read as it gets wider — past roughly 60
   characters the eye loses the start of the next line. The column is already
   capped at 640px, so this is a guarantee rather than a change: wherever a
   `.form` is dropped, it keeps a comfortable measure and centres in the room
   it is given. */
.form { max-width: 560px; margin-inline: auto; }
/* Only a GROUPED form gets the wider rhythm: 24px says "new group", 12px says
   "next field in this group". A flat form (the résumé sheets, the report
   dialog) keeps its uniform 12px, because with nothing to separate, 24px
   everywhere is just a taller version of the same undifferentiated stack. */
.form:has(> .fgroup) { gap: var(--sp-7); }

/* Profile: the head and the edit form are ONE surface.
   They were two separately-carded panels with a gap between them — the same
   person, seen above and edited below, drawn as two unrelated objects. The
   card treatment moves to the wrapper and the two halves are separated by the
   hairline that already means "same object, next part" everywhere else.
   The phone keeps the flat layout it had (`display: contents`). */
.profile-editor { display: contents; }
@media (min-width: 640px) {
  .profile-editor {
    display: block; margin: var(--sp-4) var(--sp-6) 0; max-width: 592px;
    margin-inline: auto;
    background: var(--s1); border: 1px solid var(--line);
    border-radius: var(--r-lg); box-shadow: var(--card-shadow); overflow: hidden;
  }
  .profile-editor > .profile-head,
  .profile-editor > .form {
    margin: 0; max-width: none;
    background: transparent; border: 0; border-radius: 0; box-shadow: none;
  }
  .profile-editor > .form { border-top: 1px solid var(--line); padding: var(--sp-6); }
}

/* ---------- a refused file ----------
   Every upload path in the app (the chat composer, the feed and group
   composers, a product photo) now judges the picked file BEFORE staging it,
   and says why where the person is looking rather than several seconds later
   as a storage-layer sentence in a toast.

   `pre-line`, because several files can be picked at once and each refusal is
   its own line: collapsing them would run "photo-1.heic — wrong format" into
   "photo-2.jpg — too large" as one sentence about neither file.
   `[hidden]` is restated because `.hint` sets `display`, and a display value on
   the same element beats the UA's `[hidden] { display: none }`. */
.filemsg { margin: 0; white-space: pre-line; }
.filemsg[hidden] { display: none; }
/* In the two composers it sits between the previews and the button bar; in the
   chat composer it sits above the whole control. Both need it clear of the row
   above without opening a gap when it is hidden — which `[hidden]` above
   guarantees, since a hidden element has no margin to collapse. */
.composer > .filemsg { margin: var(--sp-3) var(--sp-5) 0; }
.chat-composer > .filemsg { margin: 0 var(--sp-5) var(--sp-3); }

/* ==========================================================================
   FORM CONTROLS  ·  end
   ========================================================================== */

/* ==========================================================================
   NOTIFICATIONS · redesign 2026-07-28  ·  start
   ------------------------------------------------------------------------
   OWNED BY notifications.js. Nothing outside this block belongs to it, and
   nothing in it is used anywhere else — the rest of app.css is being edited by
   other work in parallel, so this section is deliberately self-contained and
   fenced rather than woven into the row rules above.

   Three additions to the row that already existed:
     · a titled section header (ใหม่ / ก่อนหน้านี้) — the rows differ by
       relationship, so they get sections with counts rather than one list with
       badges;
     · a small TYPE badge on the corner of the avatar, so the row says what kind
       of thing happened before the sentence is read. Its colour comes from the
       status palette variables (--amber/--blue/--purple/--sky/--green/--red),
       never from the accent: a meaning must not re-skin;
     · an inline answer row, so a friend request is answered without leaving.
   ========================================================================== */

.notif-sec {
  padding: var(--sp-5) var(--sp-4) var(--sp-2);
  font-size: var(--fs-base); font-weight: 700; color: var(--dim);
}
.notif-sec:first-child { padding-block-start: var(--sp-3); }

/* The type badge. 18px on a 40px avatar, overlapping its bottom-inline-end
   corner; `inset-inline-end` rather than `right` so it follows the avatar in
   Arabic instead of sitting on the wrong side of the face. */
.ntype-wrap { position: relative; }
.ntype {
  position: absolute; inset-block-end: -2px; inset-inline-end: -2px;
  width: 18px; height: 18px; border-radius: var(--r-pill);
  display: grid; place-items: center; font-size: 10px; line-height: 1;
  border: 2px solid var(--s1); background: var(--sky);
}
.row.notif.unread .ntype { border-color: var(--s3); }
.ntype.amber  { background: var(--amber); }
.ntype.blue   { background: var(--blue); }
.ntype.purple { background: var(--purple); }
.ntype.sky    { background: var(--sky); }
.ntype.green  { background: var(--green); }
.ntype.red    { background: var(--red); }

/* Inline ยอมรับ / ปฏิเสธ. Hidden by default and revealed only when a pending
   record was actually found, because a button the server will refuse is worse
   than no button at all. */
.nactions { display: flex; gap: var(--sp-3); margin-block-start: var(--sp-3); flex-wrap: wrap; }
.nactions[hidden] { display: none; }
.ndone { font-size: var(--fs-base); font-weight: 700; color: var(--green); }

/* The per-row overflow. It keeps its width when unhovered so the sentence does
   not reflow the moment the pointer enters the row. */
.row.notif .nmore {
  flex: 0 0 auto; align-self: flex-start; color: var(--dim);
  font-size: 16px; line-height: 1;
}
.row.notif .nmore:hover { color: var(--ink); }

/* The load-more tail. It is centred and sits clear of the last row, because a
   button flush against a list reads as another list item. */
.notif-tail { display: flex; justify-content: center; padding: var(--sp-5) var(--sp-4) var(--sp-7); }
.notif-tail .state { padding-block: var(--sp-5); }

/* ==========================================================================
   NOTIFICATIONS · redesign 2026-07-28  ·  end
   ========================================================================== */

/* ==========================================================================
   SYSTEM COVERAGE · 2026-07-28 · start
   --------------------------------------------------------------------------
   OWNED BY the shared UI layer (app.css + ui.js). Nothing here belongs to one
   screen, and every rule is deliberately reachable from CSS alone — the page
   modules were being edited in parallel, so this pass had to change how the
   app looks WITHOUT touching a line of their markup.

   WHY IT EXISTS. The owner opened the web app next to Facebook and X and asked
   why the design was several leagues worse. The token layer above is not the
   problem — it is good, and the styled screens measure up. The problem was
   measured, not guessed, by walking every class the page modules emit and
   asking whether app.css had ever heard of it:

       397 classes used · 36 UNDEFINED

   Sixteen of those thirty-six are in events.js alone, i.e. the entire events
   and ticketing feature was rendering as BARE HTML — browser-default <h1>, two
   OS-grey <button>s where a segmented control was intended, rows with no
   gutters and no gaps, and a bordered <fieldset> straight out of 1997. On top
   of that the sweep found, across the whole app:

       37 of 43 headings with no class   -> browser-default 2em / 1.5em
       14 checkbox / radio inputs        -> OS chrome, in the OS's own colours
        7 <select> with no class         -> ditto
        7 <label class="field"> wrapping a control -> the box is on the LABEL,
                                             so the control inside stayed raw

   That last one is what the owner actually saw and named. `.field` was written
   to be put ON a control; seven places put it on the <label> AROUND one, which
   draws a house-styled box with an unstyled browser widget sitting inside it.

   THE RULE THIS PASS FOLLOWS, and it is CLAUDE.md's most-repeated one: a rule
   applied per screen is a rule that gets forgotten at the next screen. So
   nothing below is scoped to a page. Where a screen invented its own word for
   something the system already has (`.tabs`/`.seg` for the segmented control,
   `.input` for a field, `.chip.ok` for `.chip.green`), the word is ADOPTED
   here rather than the markup rewritten — one definition, no drift, and the
   page module keeps working untouched.

   Status colours stay literal throughout. Nothing below derives a meaning
   colour from the accent.
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. THE PRIMARY BUTTON IS THE BRAND GRADIENT.

   HISTORY, KEPT ON PURPOSE — do not reverse this without reading it.
   This block used to say "THE ACCENT IS FLAT. The gradient is the MARK, not
   the button", and it flattened `.btn` to a solid `--a2`. Its reasoning was:
   the web invented the gradient button, the Android app never had one, its
   segmented control is "selected = NovaAccent1 + white text", so a flat button
   was the web coming into line with the design system that already existed.

   CHANGED 2026-08-14, on the owner's instruction, when the v3 theme was moved
   onto this app. Two reasons it was not simply a matter of taste:
     · The owner picked the v3 look himself, and v3's primary action IS the
       gradient. Android and the web do not have to agree pixel for pixel.
     · The file had ALREADY drifted into two philosophies on one screen: this
       block flattened `.btn` while `.segs button[aria-selected]` (defined
       ~2800 lines earlier, so it lost nothing to source order) stayed a
       gradient. A page with two kinds of primary is worse than either kind.

   `--accent` / `--accent-hover` are NOT deleted: the form controls below
   (checkbox, radio, range, progress, file button, `.radiorow`) need a single
   FLAT colour — a gradient cannot be an `accent-color` — and eight rules read
   them. They keep their job; they just no longer paint `.btn`.

   White on the gradient's darkest stop (#3B82F6) measures 3.7:1 and on #8B3DFF
   5.0:1; the button text is 14px/600, i.e. AA large-text, and the glow keeps
   the edge off the page in both themes.
   -------------------------------------------------------------------------- */
:root { --accent: var(--a2); --accent-hover: #7A2CEE; }
:root[data-theme="light"] { --accent-hover: #7A2CEE; }
/* Restated here rather than left to the earlier definition, because this block
   is where a reader looks for the answer to "what colour is a button". */
.btn { background: var(--brand); box-shadow: var(--brand-glow); }
.btn:hover { background: var(--brand-hover); filter: none; }
/* The variants restate their own background AFTER the line above, or the brand
   gradient would leak into ghost/quiet/danger, all three of which are defined
   earlier in the file and would otherwise lose to it on source order. */
.btn.ghost { background: transparent; box-shadow: none; }
.btn.ghost:hover { background: var(--hover); }
.btn.quiet { background: var(--s3); box-shadow: none; }
.btn.quiet:hover { background: var(--line); }
.btn.danger { background: transparent; box-shadow: none; }
.btn.danger:hover { background: color-mix(in srgb, var(--red) 12%, transparent); }
.btn:disabled { box-shadow: none; }
/* `.primary` is used on 1 button and has never been defined; it means "the
   default button", which `.btn` already is. Stated so it is not a silent no-op
   that the next reader assumes is doing something. */
.btn.primary { background: var(--brand); color: var(--on-accent); }
.btn.primary:hover { background: var(--brand-hover); }

/* Thai sets a tone mark ABOVE an upper vowel, so a Thai line box is taller than
   a Latin one at the same size — "ที่นั่งเหลือ" stacks two levels over the
   baseline. `line-height: 1.2` on a button is a Latin number: it clips the tone
   marks off the language this app is actually written in. 1.35 is the smallest
   value that clears the stack at every button size. */
.btn { line-height: 1.35; }
.segs button { line-height: 1.35; }

/* --------------------------------------------------------------------------
   2. FORM CONTROLS — the browser's chrome belongs to neither theme.

   These are element selectors on purpose. A class-based fix only reaches the
   call sites someone remembered to change, which is precisely how fourteen raw
   checkboxes accumulated; an element rule reaches the fifteenth too.
   -------------------------------------------------------------------------- */

/* Checkbox and radio. Drawn, not tinted: `accent-color` alone leaves the OS
   box shape and the OS focus ring, which is why these still read as system
   widgets on a dark page. The ::after marks are geometry, not an icon font, so
   they cost no request and cannot fail to load. */
input[type="checkbox"], input[type="radio"] {
  appearance: none; -webkit-appearance: none;
  flex: 0 0 auto; margin: 0; width: 18px; height: 18px;
  border: 1.5px solid var(--line); border-radius: var(--r-xs);
  background: var(--s2); cursor: pointer; position: relative;
  transition: background-color .12s ease, border-color .12s ease;
}
input[type="radio"] { border-radius: var(--r-pill); }
input[type="checkbox"]:hover, input[type="radio"]:hover { border-color: var(--accent); }
input[type="checkbox"]:checked, input[type="radio"]:checked {
  background: var(--accent); border-color: var(--accent);
}
input[type="checkbox"]:checked::after {
  content: ""; position: absolute; inset: 0;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E")
    center / 12px 12px no-repeat;
}
input[type="radio"]:checked::after {
  content: ""; position: absolute; inset: 4px; border-radius: var(--r-pill); background: #fff;
}
input[type="checkbox"]:disabled, input[type="radio"]:disabled { opacity: .5; cursor: not-allowed; }

/* A <select> with no class. `select.chip` above already handles the filter
   pills; this is the same treatment on the FIELD rung, for the seven selects
   that are a form control rather than a filter. */
select:not(.chip):not(.seg) {
  appearance: none; -webkit-appearance: none;
  /* The box has to be restated here. `appearance: none` removes the UA border
     as well as the OS chrome, so stripping it WITHOUT drawing a replacement
     leaves an invisible control — worse than the raw widget it replaced. */
  padding-block: var(--sp-5); border-radius: var(--r-md);
  background-color: var(--s2); border: 1px solid var(--line); color: var(--ink);
  padding-inline: var(--sp-5) var(--sp-7);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238A86A9' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 10px center; background-size: 14px;
  cursor: pointer;
}
html[dir="rtl"] select:not(.chip):not(.seg) {
  padding-inline: var(--sp-7) var(--sp-5); background-position: left 10px center;
}
/* The option list is drawn by the OS; `color-scheme` on :root is what makes it
   dark, and it is already set. Stating the two colours covers the browsers that
   honour them on the element instead. */
select:not(.chip) option { background: var(--s2); color: var(--ink); }
select:not(.chip):not(.seg):hover { border-color: color-mix(in srgb, var(--a2) 40%, var(--line)); }
select:not(.chip):not(.seg):focus-visible { border-color: var(--a2); }

/* Native date / time / number spinners. Left alone they paint a light-grey OS
   widget and a black calendar glyph on a near-black field. */
input[type="date"], input[type="time"], input[type="datetime-local"], input[type="month"] {
  min-height: 42px;
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
input[type="month"]::-webkit-calendar-picker-indicator {
  filter: invert(var(--icon-invert, 1)) opacity(.6); cursor: pointer;
}
:root { --icon-invert: 1; }
:root[data-theme="light"] { --icon-invert: 0; }
@media (prefers-color-scheme: light) { :root[data-theme="auto"] { --icon-invert: 0; } }
input[type="number"] { appearance: textfield; -moz-appearance: textfield; }

input[type="range"] { accent-color: var(--accent); }
progress { accent-color: var(--accent); }

/* A raw <input type="file"> is the strongest "unfinished" signal a page can
   emit, and it is the one control that cannot be styled through its own box.
   The app's real answer is `imagePicker()` in ui.js (a <label> with the input
   laid transparently over it) and every current site uses it — this is the
   SAFETY NET for the next one that does not, so a missed call site degrades to
   house chrome instead of to Chrome's grey "Choose File". The two overlay
   patterns (.attachbtn, .ff-pick) set opacity:0 and are unaffected. */
input[type="file"] { font-size: var(--fs-base); color: var(--dim2); max-width: 100%; }
input[type="file"]::file-selector-button {
  margin-inline-end: var(--sp-4); padding: 6px var(--sp-5);
  border: 1px solid var(--line); border-radius: var(--r-btn);
  background: transparent; color: var(--ink);
  font: inherit; font-size: var(--fs-base); font-weight: 600; cursor: pointer;
}
input[type="file"]::file-selector-button:hover { border-color: var(--accent); background: var(--hover); }

/* `<label class="field">` wrapping a control — the seven-site bug named at the
   top of this block. `.field` was written to go ON a control; put on the label
   AROUND one it draws the house box and leaves a raw browser widget inside it.

   Rather than ask seven call sites to change (and the eighth to remember), the
   composite is defined: the label stops being a box, its <span> becomes the
   house `.label` line, and the control inside becomes the field. The markup
   `<label class="field"><span>คำอธิบาย</span><textarea></textarea></label>`
   now renders exactly as the house `.label` + `.field` pair does. */
label.field {
  padding: 0; background: transparent; border: 0; border-radius: 0;
  display: flex; flex-direction: column; gap: 5px; align-items: stretch;
}
label.field:hover { border-color: transparent; }
label.field > span {
  font-size: var(--fs-sm); font-weight: 600; color: var(--dim);
  margin-inline-start: 2px; text-align: start;
}
label.field > :is(input, textarea, select) {
  width: 100%; padding: var(--sp-5); border-radius: var(--r-md);
  background: var(--s2); border: 1px solid var(--line); color: var(--ink);
  outline: none;
}
label.field > textarea { resize: vertical; min-height: 84px; line-height: 1.6; }
label.field > :is(input, textarea, select):hover {
  border-color: color-mix(in srgb, var(--a2) 40%, var(--line));
}
/* The ring is put back on the control that actually has focus, in the same rule
   that takes the doubled one away — the file's standing promise is that nothing
   removes an outline without restoring one. */
label.field > :is(input, textarea, select):focus-visible {
  border-color: var(--a2); outline: 2px solid var(--a2); outline-offset: 2px;
}
/* A checkbox or radio inside a `.field` label is a ROW, not a stacked field. */
label.field:has(> input[type="checkbox"]), label.field:has(> input[type="radio"]) {
  flex-direction: row; align-items: center; gap: var(--sp-4);
}
label.field > :is(input[type="checkbox"], input[type="radio"]) {
  width: 18px; height: 18px; padding: 0; border-radius: var(--r-xs);
}
label.field > input[type="radio"] { border-radius: var(--r-pill); }

/* A bare <input> or <textarea> with no class at all — 14 of them, and the UA
   paints them in its own colours on both themes. checkbox / radio / file /
   range are excluded — each is drawn above.

   EVERY SELECTOR HERE IS WRAPPED IN `:where()`, AND THAT IS THE WHOLE POINT.
   ----------------------------------------------------------------------------
   The block's stated promise was that `:not([class])` keeps this from fighting
   a screen that did its job. It does not, and the promise cost three screens
   the day it shipped. `:not([class])` only means "the control carries no class
   of its own" — it says nothing about a rule that reaches the control through
   its PARENT, which is how a container styles a control it owns:

       .searchbox input        (0,1,1) — the global search field
       .composer textarea      (0,1,1) — the feed post composer
       .composer-bar textarea  (0,1,1) — the chat input, on every chat screen

   Against those, the old selector measured (0,5,1) for the input (five `:not()`
   arguments each count as the thing inside them) and (0,1,1) for the textarea —
   which ties and then wins on source order, because this block is at the end of
   the file. So the safety net outranked the three most-used controls in the
   product: the search field lost `padding-inline: 36px 32px` and drew its
   placeholder underneath its own magnifier, and both composers went from a
   36–42px single-line bar to an 84px bordered box with a resize grip.

   `:where()` has a specificity of exactly ZERO, so these rules now sit below
   every author rule in the file while still beating the UA default — which is
   the only thing a safety net was ever supposed to do. A screen that styles its
   own control wins automatically, with no exclusion list to maintain here and
   nothing to remember at the next screen. The `:not()`s stay inside for what
   they actually say: "do not paint a control that opted out".

   The same argument applies to any element-level rule added below: put it in
   `:where()` unless it is genuinely meant to override a class. */
:where(input:not([class]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="range"])),
:where(textarea:not([class])) {
  width: 100%; padding: var(--sp-5); border-radius: var(--r-md);
  background: var(--s2); border: 1px solid var(--line); color: var(--ink); outline: none;
}
:where(textarea:not([class])) { resize: vertical; min-height: 84px; line-height: 1.6; }
:where(input:not([class]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="range"]):hover),
:where(textarea:not([class]):hover) { border-color: color-mix(in srgb, var(--a2) 40%, var(--line)); }
:where(input:not([class]), textarea:not([class]))::placeholder { color: var(--dim2); }

/* `.input` — projects.js's word for a field, on 8 controls. Adopted, not
   renamed: one definition beats a rewrite that the next module misses. */
.input {
  width: 100%; padding: var(--sp-5); border-radius: var(--r-md);
  background: var(--s2); border: 1px solid var(--line); color: var(--ink); outline: none;
}
.input:hover { border-color: color-mix(in srgb, var(--a2) 40%, var(--line)); }
.input:focus-visible { border-color: var(--a2); outline: 2px solid var(--a2); outline-offset: 2px; }
.input::placeholder { color: var(--dim2); }

/* A bare <fieldset> draws a 2px inset border and a floated <legend>. Nothing in
   this app wants that; the grouping is carried by the section label. */
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
fieldset > legend { padding: 0; font-size: var(--fs-base); font-weight: 700; color: var(--dim); }

/* --------------------------------------------------------------------------
   3. HEADINGS. 37 of 43 carry no class, so they were rendering at the UA's
   2em / 1.5em / 1.17em with the UA's margins — three sizes the type scale does
   not contain, which is most of why "everything is a similar size" and
   "one thing is enormous" were both true on the same screen.

   Mapped onto the eight-step scale. A heading that DOES carry a class is
   untouched: `:not([class])` keeps this from fighting the styled screens.
   -------------------------------------------------------------------------- */
/* `:where()` for the same reason as the form controls above, and it was already
   biting here too. ELEVEN container rules style an unclassed heading through
   their parent and every one of them is defined EARLIER in this file:
   `.auth h1`, `.resume-head h1`, `.serieshead h2`, `.profile-head h2`,
   `.pdetail h2`, `.dialog h3`, `.panel h3`, `.sheet-head h3`, `.subhead h3`,
   `.resume-doc h2`, `.railcard h4`. All are (0,1,1); the old selectors here were
   (0,1,1) as well, so they tied and won on source order — silently taking over
   `font-size`, `line-height` and `margin` from each of them. `.resume-head h1`
   asks for 1.6rem/1.25 and was getting `--fs-h`/1.35 instead. At zero
   specificity the scale is what it was written to be: a default for a heading
   nobody has placed, and never an override of one somebody has. */
:where(h1:not([class]), h2:not([class]), h3:not([class]), h4:not([class])) {
  margin: 0; font-weight: 700; letter-spacing: -.01em; line-height: 1.35;
}
:where(h1:not([class])) { font-size: var(--fs-h); }
:where(h2:not([class])) { font-size: var(--fs-title); }
:where(h3:not([class])) { font-size: var(--fs-row); }
:where(h4:not([class])) { font-size: var(--fs-base); color: var(--dim); }

/* <summary> paints a UA disclosure triangle that sits outside every other
   affordance in the app. */
summary { cursor: pointer; list-style: none; font-size: var(--fs-row); font-weight: 600; }
summary::-webkit-details-marker { display: none; }
summary::before {
  content: ""; display: inline-block; width: 0; height: 0; margin-inline-end: var(--sp-4);
  border-inline-start: 5px solid currentColor; border-block: 4px solid transparent;
  vertical-align: middle; transition: transform .15s ease; color: var(--dim);
}
html[dir="rtl"] summary::before { transform: scaleX(-1); }
details[open] > summary::before { transform: rotate(90deg); }
html[dir="rtl"] details[open] > summary::before { transform: rotate(90deg) scaleX(-1); }

/* --------------------------------------------------------------------------
   4. TYPE UTILITIES the modules already use and the file never defined.
   `.small`, `.mono` and `.xs` appear in markup as if they were house words.
   They are now.
   -------------------------------------------------------------------------- */
.small { font-size: var(--fs-sm); }
.xs    { font-size: var(--fs-micro); }
/* `.dim` was the same kind of orphan and was found the same way: `events.js`
   writes `class="dim small"` in four places and `trending.js` in one, and there
   was no global rule — so "secondary" text rendered at full ink, i.e. exactly as
   loud as the sentence above it. events.js had been unreachable since it was
   written, which is why nobody had ever seen it. Found 2026-07-29 while wiring
   the events route. */
.dim   { color: var(--dim); }
.mono  { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .04em; }

/* --------------------------------------------------------------------------
   5. THE ORPHAN VOCABULARY.

   Screens that were written to words app.css never adopted. Each is mapped
   onto an existing primitive rather than given a look of its own — the point
   is that there is ONE list row, ONE card, ONE segmented control, not that
   every screen gets styled.
   -------------------------------------------------------------------------- */

/* `.screen` / `.screen-head` / `.body` — events.js's shell words. The house
   equivalents are the column itself and `.colhead` / `.list`. */
.screen { display: block; min-width: 0; }
.screen-head {
  display: flex; align-items: center; gap: var(--sp-4);
  min-height: var(--colhead-h); padding: var(--sp-4) var(--sp-6) var(--sp-2);
}
.screen-head h1, .screen-head h2 {
  flex: 1; min-width: 0; margin: 0;
  font-size: var(--fs-title); font-weight: 700; letter-spacing: -.01em; line-height: 1.35;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* `.body` is a list container. The house gutters and the house gap — this is
   what was missing when the event rows ran edge to edge with nothing between
   them, which is the single biggest reason that screen read as unfinished. */
.screen > .body { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); }

/* `.tabs` + `.seg` — a second name for the two-segment control. Same geometry
   as `.segs`, and `.on` is honoured alongside `aria-selected` because these
   buttons carry the class and not the attribute. */
.tabs { display: flex; gap: 0; padding: 3px; margin: var(--sp-4) var(--sp-6) 0;
  background: var(--s2); border-radius: var(--r-md); }
.tabs .seg, .seg {
  flex: 1; min-width: 0; padding: var(--sp-4); border: 0; border-radius: var(--r-sm); cursor: pointer;
  background: transparent; color: var(--dim);
  font: inherit; font-size: var(--fs-base); font-weight: 600; line-height: 1.35;
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
}
.tabs .seg:hover { color: var(--ink); }
/* The gradient, matching `.segs button[aria-selected="true"]` — `.tabs .seg` is
   the adopted alias for the SAME control, so it cannot be a different colour
   from it. (It was flat while `.segs` was a gradient; that is the two-
   philosophies drift the button block above records.) */
.tabs .seg.on, .tabs .seg[aria-selected="true"], .seg.on {
  background: var(--brand); color: #fff; box-shadow: var(--brand-glow);
}

/* Status chips written with the tone word rather than the colour word. Mapped
   to the SAME literal palette entries, never re-derived — `ok` is the green in
   CLAUDE.md's table, not a second green. */
.chip.ok   { background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green); }
.chip.bad  { background: color-mix(in srgb, var(--red) 16%, transparent);   color: var(--red); }
.chip.warn { background: color-mix(in srgb, var(--amber) 16%, transparent); color: var(--amber); }
.chip.info { background: color-mix(in srgb, var(--blue) 16%, transparent);  color: var(--blue); }

/* Events — detail, facts, organiser. A `.fact` is a labelled line of record:
   one per row, dim, and never competing with the title above it. */
.event-detail { display: flex; flex-direction: column; gap: var(--sp-3);
  padding: var(--sp-5) var(--sp-6) var(--sp-4); }
.event-detail > h2 { margin: 0 0 var(--sp-2); font-size: var(--fs-h); font-weight: 700;
  letter-spacing: -.01em; line-height: 1.35; }
.fact { display: flex; align-items: flex-start; gap: var(--sp-4);
  font-size: var(--fs-sm); color: var(--dim); line-height: 1.6; }
.fact.warn { color: var(--amber); }
.fact.bad  { color: var(--red); }
.desc { margin: var(--sp-3) 0 0; font-size: var(--fs-body); color: var(--ink); line-height: 1.7; }
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-4);
  padding-block-start: var(--sp-4); }
.organiser { display: block; }
.organiser-box {
  margin: var(--sp-5) 0 0; padding: var(--sp-5); border-radius: var(--r-lg);
  background: var(--s1); border: 1px solid var(--line); box-shadow: var(--card-shadow);
  display: flex; flex-direction: column; gap: var(--sp-4);
}
.organiser-box > h3 { margin: 0; font-size: var(--fs-base); font-weight: 700; color: var(--dim); }
.door, .guests { display: flex; flex-direction: column; gap: var(--sp-3); }
.qr-wrap { display: grid; place-items: center; padding: var(--sp-5);
  background: #fff; border-radius: var(--r-md); }
.qr-wrap svg, .qr-wrap img { width: 100%; max-width: 232px; height: auto; }
/* The door scanner's verdict. It is the one thing a person on the door reads at
   arm's length, so it is a filled block in the status palette, not a sentence. */
.scan-result {
  display: flex; flex-direction: column; gap: var(--sp-2);
  padding: var(--sp-5); border-radius: var(--r-md);
  font-size: var(--fs-row); font-weight: 600;
  background: color-mix(in srgb, var(--green) 14%, transparent);
  color: var(--green); border: 1px solid color-mix(in srgb, var(--green) 32%, transparent);
}
.scan-result.bad  { background: color-mix(in srgb, var(--red) 14%, transparent);
  color: var(--red); border-color: color-mix(in srgb, var(--red) 32%, transparent); }
.scan-result.warn { background: color-mix(in srgb, var(--amber) 14%, transparent);
  color: var(--amber); border-color: color-mix(in srgb, var(--amber) 32%, transparent); }
.scan-result .small { font-weight: 400; opacity: .9; }

/* Groups — header, rules disclosure, join box. */
.grouphead { display: flex; flex-direction: column; gap: var(--sp-4);
  padding: var(--sp-5) var(--sp-6) var(--sp-4); }
.grouphead > h2 { margin: 0; font-size: var(--fs-h); font-weight: 700; letter-spacing: -.01em; }
.rules { border: 1px solid var(--line); border-radius: var(--r-md); background: var(--s1);
  padding: var(--sp-4) var(--sp-5); }
.rules > summary { color: var(--ink); }
.rules > :not(summary) { margin: var(--sp-4) 0 0; font-size: var(--fs-sm); color: var(--dim);
  line-height: 1.7; }
.joinbox { display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-4);
  padding: var(--sp-5); border-radius: var(--r-lg);
  background: var(--s1); border: 1px solid var(--line); box-shadow: var(--card-shadow); }
.joinbox > p { margin: 0; font-size: var(--fs-sm); color: var(--dim); }

/* Report / profile forms — the form primitive already exists; these two words
   only need to resolve to it. */
.report-form, .profile-form { display: flex; flex-direction: column; gap: var(--sp-5); }
.reasons { display: flex; flex-direction: column; gap: var(--sp-3); }
/* A reason is a ROW you tap, not a bullet with a dot beside it. Same density
   and same radius as every other row in the app. */
.radiorow {
  display: flex; align-items: center; gap: var(--sp-4);
  padding: 9px var(--sp-5); border-radius: var(--r-md);
  background: var(--s2); border: 1px solid transparent;
  font-size: var(--fs-row); cursor: pointer;
  transition: border-color .12s ease;
}
.radiorow:hover { border-color: var(--line); }
.radiorow:has(input:checked) { border-color: var(--accent); }
.radiorow:has(input:focus-visible) { outline: 2px solid var(--a2); outline-offset: 2px; }
.radiorow > span { flex: 1; min-width: 0; }

/* `.row.end` is an ACTION BAR, not a list row — it must not inherit the row's
   card background or its pointer cursor, or a pair of buttons reads as a
   tappable list item. */
.row.end {
  justify-content: flex-end; background: transparent; border-color: transparent;
  cursor: default; padding-inline: 0;
}
.row.end:hover { border-color: transparent; background: transparent; }
.gap { gap: var(--sp-4); }

/* Remaining orphans, each mapped to the nearest existing primitive so the
   screen is consistent rather than merely no longer broken. */
.addr, .takeaway { font-size: var(--fs-sm); color: var(--dim); line-height: 1.6; }
.addrpick { display: flex; flex-direction: column; gap: var(--sp-3); }
.section { display: flex; flex-direction: column; gap: var(--sp-4);
  margin: var(--sp-4) var(--sp-6) 0; padding: var(--sp-5); border-radius: var(--r-lg);
  background: var(--s1); border: 1px solid var(--line); box-shadow: var(--card-shadow); }
.scanvideo { width: 100%; max-width: 420px; border-radius: var(--r-md); background: #000;
  display: block; margin-inline: auto; }
.expired { font-size: var(--fs-sm); color: var(--amber); }
.action { display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap; }
.event-row { /* it is a `.row`; the modifier exists only as a hook */ }
.d { font-size: var(--fs-sm); color: var(--dim); }
.filtered { color: var(--dim); }
.event, .events { display: block; }

/* --------------------------------------------------------------------------
   6. THAI LINE HEIGHT.

   Thai stacks up to two marks above the baseline (สระบน + วรรณยุกต์) and one
   below, so a line box tuned to Latin clips the language this app is written
   in. Body prose is already 1.55; the places that were tighter than that, and
   are real sentences rather than single-line rows, are brought up. Rows are
   deliberately NOT changed: they are `nowrap` single lines at 1.55, which
   clears the stack, and raising them would cost the density the owner asked
   for in the first place.
   -------------------------------------------------------------------------- */
p, li { line-height: 1.7; }
.state .detail, .hint, .muted { line-height: 1.6; }

/* The two most-read blocks of prose in the whole app, and the two that were
   tightest: a post body was 1.5 and a chat bubble inherited 1.55, both of them
   `pre-wrap` multi-line Thai. These are the sentences people actually read, so
   they get the prose value rather than the row value. */
.post .body { line-height: 1.65; }
.msg .bubble { line-height: 1.6; }

/* --------------------------------------------------------------------------
   7. THE GRADIENT COMES OFF THE ONE SURFACE THAT STILL WORE IT.

   `.msg.mine .bubble` was `var(--brand)` — the full three-stop gradient, on
   every message the user has ever sent. The file's own rule two hundred lines
   up says the accent is worn by "the primary action and the logo, surfaces
   never", and a chat bubble is a surface; it is also the single most-repeated
   object in the product, so it is where a gradient reads loudest. Messenger and
   X both use a flat accent bubble, and so does the Android app.

   The corner geometry is restated verbatim because this rule has to come after
   the original to win, and dropping it would square the speaker's corner.
   -------------------------------------------------------------------------- */
.msg.mine .bubble {
  background: var(--accent); color: #fff;
  border-radius: var(--r-lg); border-end-start-radius: 16px; border-end-end-radius: 5px;
}

/* --------------------------------------------------------------------------
   8. LANDED WHILE THIS PASS WAS RUNNING.

   The sweep was re-run at the end and found five classes that did not exist
   when it was first run — chat attachments and a feed reaction, added by the
   work happening in the page modules in parallel. They are defined here rather
   than reported as a follow-up, which is the whole point of owning the shared
   layer: the vocabulary is closed again the same day it opened.
   -------------------------------------------------------------------------- */
.att-card {
  display: flex; align-items: center; gap: var(--sp-4);
  margin-block-start: 5px; padding: var(--sp-4) var(--sp-5);
  border-radius: var(--r-md); background: var(--s3); color: var(--ink);
  font-size: var(--fs-sm); min-width: 0;
}
.att-card:hover { background: var(--line); }
.msg.mine .att-card { background: rgba(255, 255, 255, .18); color: #fff; }
.msg.mine .att-card:hover { background: rgba(255, 255, 255, .26); }
.att-ico { flex: 0 0 auto; font-size: var(--fs-title); line-height: 1; }
.att-card > :not(.att-ico) { min-width: 0; overflow-wrap: anywhere; }
.att-audio { margin-block-start: 5px; }
.att-audio audio { width: 100%; max-width: 260px; display: block; }

/* An emoji reaction sitting on a post. It is content, not chrome, so it takes
   no colour of its own — the glyph carries its own. */
.reaction { font-size: var(--fs-title); line-height: 1; }

/* A form inside a bottom sheet / dialog. Same stack as `.form`, without the
   column padding: the sheet already owns its gutters. */
.sheetform { display: flex; flex-direction: column; gap: var(--sp-5); min-width: 0; }

/* ==========================================================================
   SYSTEM COVERAGE · 2026-07-28 · end
   ========================================================================== */

/* ==========================================================================
   ตัวดูรูปภาพเต็มหน้าจอ (lightbox)

   COLOUR. This is the one surface in the app that does NOT take the theme
   tokens, and the reason is the same one the status palette gives: the meaning
   must not re-skin. A photo viewer is a blackout behind a picture — in light
   mode a `--s1` backdrop would put a pale grey sheet directly against the
   photo's own edges, so a light photo would have no boundary at all and a dark
   one would look like a hole. Every viewer people already use (the phone's
   gallery, Facebook, the OS preview) is black in both themes for exactly this
   reason. So the literals below are deliberate, like `--red` on a count badge,
   and the chrome on top of them is white at fixed opacities.

   Z-INDEX. Above everything: scrim 70, menu 80, so the viewer is 90. It has to
   cover the chat dock (55) and the tab bar (40) as well, because it is opened
   from screens that have both.
   ========================================================================== */

.lb {
  position: fixed; inset: 0; z-index: 90;
  display: flex; flex-direction: column;
  background: rgba(3, 3, 8, var(--lb-dim, .96));
  /* No backdrop blur: it costs a full-screen filter on every frame of a pinch,
     and there is nothing behind the photo worth seeing through. */
  animation: lb-in .14s ease-out;
  user-select: none; -webkit-user-select: none;
  overscroll-behavior: contain;
}
@keyframes lb-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .lb { animation: none; } }

/* ---------- the top bar ---------- */
.lb-bar {
  display: flex; align-items: center; gap: var(--sp-4);
  padding: var(--sp-4) var(--sp-5);
  padding-top: max(var(--sp-4), env(safe-area-inset-top));
  flex: 0 0 auto; position: relative; z-index: 2;
}
.lb-spacer { flex: 1; min-width: 0; }
.lb-count {
  font-size: var(--fs-row); font-weight: 700; color: #fff;
  font-variant-numeric: tabular-nums; letter-spacing: .02em;
}
/* Two states, and they must not look the same: "still fetching the sharp one"
   is neutral, "you are looking at the small one and that is all there is" is
   amber — the literal status meaning of "something did not happen". */
.lb-note { font-size: var(--fs-sm); color: rgba(255, 255, 255, .66); max-width: 46vw;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lb-note.warn { color: var(--amber); }

.lb-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; flex: 0 0 auto;
  border: 0; border-radius: var(--r-pill); text-decoration: none;
  background: rgba(255, 255, 255, .12); color: #fff;
  font-size: var(--fs-row); line-height: 1; cursor: pointer;
}
.lb-btn:hover { background: rgba(255, 255, 255, .22); }
/* The stylesheet lost its focus rings once by resetting `outline` without
   putting one back. The global :focus-visible ring uses --a2, which is dark
   purple against near-black — legible, but only just — so the viewer states its
   own in white. Restored in the same rule that changes it, per that lesson. */
.lb :is(button, a):focus-visible {
  outline: 2px solid #fff; outline-offset: 2px;
}

/* ---------- the stage ----------
   `touch-action: none` is load-bearing: it is what stops a pinch or a swipe
   inside the viewer from also being read as a page zoom / page scroll. The
   Android side records the same class of conflict (a horizontal drag detector
   eating the drags a parent pager needs); here the cure is to make sure only
   one handler ever sees the touch. */
.lb-stage {
  flex: 1 1 auto; min-height: 0; position: relative;
  display: flex; align-items: center; justify-content: center;
  overflow: hidden; touch-action: none;
}
.lb-img {
  max-width: 100%; max-height: 100%;
  object-fit: contain; display: block;
  transform-origin: center center;
  will-change: transform;
  -webkit-user-drag: none;
  transition: transform .16s ease-out;
}
/* While a gesture is running the transform must track the finger exactly —
   an easing here would lag behind and read as a dropped frame. */
.lb.dragging .lb-img, .lb.zoomed .lb-img { transition: none; }
/* The 800px placeholder, blown up. A hair of blur is honest: it says "this is
   not the sharp one yet" without a spinner covering the picture, and it is what
   makes the swap to full resolution read as arriving rather than as flicker. */
.lb-img.soft { filter: blur(.4px); }
.lb.zoomed .lb-img { cursor: grab; }
.lb.dragging .lb-img { cursor: grabbing; }

/* ---------- prev / next ---------- */
.lb-nav {
  position: absolute; top: 50%; transform: translateY(-50%);
  width: 44px; height: 64px; border: 0; z-index: 2;
  border-radius: var(--r-md); background: rgba(255, 255, 255, .10); color: #fff;
  font-size: 26px; line-height: 1; cursor: pointer;
}
.lb-nav:hover { background: rgba(255, 255, 255, .2); }
.lb-nav.prev { inset-inline-start: var(--sp-5); }
.lb-nav.next { inset-inline-end: var(--sp-5); }
/* ‹ and › are direction, not decoration: in Arabic "previous" is on the right. */
html[dir="rtl"] .lb-nav { transform: translateY(-50%) scaleX(-1); }

/* ---------- the caller's action bar ----------
   Below the stage, never over it: `.lb-stage` is `flex: 1`, so this row takes
   its height out of the PHOTO's height instead of sitting on top of the
   picture. That is the whole small-screen story — there is no overlap to solve
   at 390px because there is never any overlap.

   The glyphs are the post card's own `ICON.heart` / `ICON.comment`, handed in
   by the caller, so the two surfaces speak one vocabulary. The `on` colour is
   `--a1`, matching `.post .act.on` exactly. */
.lb-acts {
  flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center; gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-5) 0;
  position: relative; z-index: 2;
}
.lb-act {
  display: inline-flex; align-items: center; gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-5); border: 0; border-radius: var(--r-btn);
  background: rgba(255, 255, 255, .12); color: #fff;
  font-family: inherit; font-size: var(--fs-row); font-weight: 600;
  line-height: 1; cursor: pointer;
}
.lb-act:hover { background: rgba(255, 255, 255, .22); }
.lb-act.on { color: var(--a1); }
.lb-act .ic { display: inline-flex; }
.lb-act svg { width: 18px; height: 18px; display: block; }
.lb-act .n { font-variant-numeric: tabular-nums; opacity: .85; }
/* Under ~420px the three labels wrap the row onto two lines and eat the photo.
   The glyph plus the count still says which button is which, and the accessible
   name is on `aria-label`, so nothing is lost to a screen reader. */
@media (max-width: 420px) {
  .lb-act .tx { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
  .lb-act { padding: var(--sp-3) var(--sp-5); }
}

.lb-hint {
  flex: 0 0 auto; margin: 0; text-align: center;
  padding: var(--sp-4) var(--sp-5);
  padding-bottom: max(var(--sp-4), env(safe-area-inset-bottom));
  font-size: var(--fs-micro); color: rgba(255, 255, 255, .44);
}
/* On a touch screen there is no double-click and no drag-with-a-mouse, and the
   sentence would be advice for a device the reader is not holding. */
@media (pointer: coarse) { .lb-hint { display: none; } }
@media (max-width: 520px) {
  .lb-nav { width: 38px; height: 54px; font-size: 22px; }
  .lb-note { max-width: 38vw; }
}

/* ---------- inline post panel (who posted it + comments, without leaving) ---
   FB puts the photo and the comment thread side by side on a wide window and
   stacked on a narrow one; `lightbox.js` needs a `.lb-main` wrapper around the
   stage for that (see the report handed to the coordinating session for the
   exact JS patch — these rules target the markup it adds). The panel uses the
   ordinary app surface tokens, not the viewer's black/white overlay palette,
   because it is reading text and typing, not looking at a photo. */
.lb-main { flex: 1 1 auto; min-height: 0; display: flex; overflow: hidden; position: relative; }
.lb-panel {
  flex: 0 0 380px; max-width: 42vw; min-width: 280px;
  background: var(--s2); color: var(--ink);
  display: flex; flex-direction: column;
  overflow-y: auto; -webkit-overflow-scrolling: touch;
  border-inline-start: 1px solid var(--line);
}
@media (max-width: 860px) {
  .lb-main { flex-direction: column; }
  /* `dvh`, not `vh`: on a stacked mobile layout the photo and `.lb-panel`
     (comments + the sticky composer) share this column, and `vh` is fixed to
     the LARGE viewport — it does not shrink when the on-screen keyboard opens,
     so the photo would keep eating 48% of a viewport that no longer exists and
     push the composer up behind the keyboard. `dvh` tracks the real, current
     viewport, so the stage shrinks and the composer stays reachable. */
  .lb-stage { flex: 0 0 auto; max-height: 48dvh; }
  .lb-panel { flex: 1 1 auto; max-width: none; min-width: 0; border-inline-start: 0; border-top: 1px solid var(--line); }
}
.lb-panel-post { padding: var(--sp-4) var(--sp-5); display: flex; flex-direction: column; gap: var(--sp-3); }
/* The header is `.head` reused verbatim from the post card (`postHeadNode`), but
   NOT its stylesheet: every rule that shapes it is scoped `.post .head`, and
   this panel is not a `.post`. So the one line here used to zero a gap the
   element never had, while the avatar, the name and the timestamp arrived as
   three unstyled blocks stacked on top of each other — the author's photo sat
   above their name instead of beside it, in the viewer that opens off every
   photo in the feed. The card's own values, restated for the one other place
   that mounts this node. */
.lb-panel-post .head { display: flex; align-items: center; gap: var(--sp-4); padding: 0; margin: 0; }
.lb-panel-post .who { display: flex; align-items: center; gap: var(--sp-4); cursor: pointer; min-width: 0; }
.lb-panel-post .who > div { min-width: 0; }
.lb-panel-post .who .name { font-size: var(--fs-row); font-weight: 700; letter-spacing: -.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lb-panel-post .when { font-size: var(--fs-micro); color: var(--dim2); line-height: 1.35; }
.lb-body.clamp {
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
  overflow: hidden; margin: 0;
}
.lb-more { align-self: flex-start; padding: 0; margin-top: calc(-1 * var(--sp-2)); }
.lb-panel-comments {
  display: flex; flex-direction: column; gap: var(--sp-3);
  padding-top: var(--sp-3); border-top: 1px solid var(--line);
}
/* A comment row was styled for a full-width page column; the panel is
   narrower, so its own gutter (avatar + bubble) needs one less step. */
.lb-panel-comments .comment { gap: var(--sp-2); }

/* The thumbnails that open it. `zoom-in` is the one cursor that says "this
   grows", and it is set here as well as in JS so a photo rendered before the
   binder runs still reads correctly. */
.post .shots img, .msg .att img { cursor: zoom-in; }

/* "+3" on the last tile of a four-up grid whose post holds more. The grid is
   `position: static`, so it needs to become the containing block; `overflow:
   hidden` is already on `.shots`, which keeps the badge inside the rounded
   corner. Not a CountBadge: that component means "something is waiting for
   you" and is red for that reason — this is a quantity of content, so it takes
   the neutral scrim the sold-out/age badges on a photo already use. */
.post .shots { position: relative; }
.post .shots-more {
  position: absolute; inset-inline-end: 0; bottom: 0;
  width: 50%; height: 50%;
  display: grid; place-items: center;
  background: rgba(4, 4, 10, .58);
  color: #fff; font-size: var(--fs-h); font-weight: 700;
  font-variant-numeric: tabular-nums; pointer-events: none;
}
/* Only `.n4` can ever carry it — `shots` is `allShots.slice(0, 4)`, so there
   are extras only when four are already drawn. No `.n1`/`.n2` variant is
   written, because a rule that cannot be reached is a rule the next reader has
   to work out is dead. */

/* ==========================================================================
   คำเชิญให้โทร (callinvite.js) — the strip above a business chat's composer
   ==========================================================================
   It sits between the message scroller and the composer, so it must never
   grow without bound: the composer is what the person came here to use. The
   card is `--s2`, one step up from the room's background, because it is a
   control and not a message — a bubble-coloured card here reads as something
   somebody sent.

   `.inbox-invite` is empty most of the time (an ordinary chat between two
   people, or a staff member without `inbox.reply`). `:empty` therefore takes
   the margin back rather than leaving a band of padding above the composer —
   the same shape of bug as the profile stats band, where leftover height read
   as a deliberate gap. */
.inbox-invite { padding: var(--sp-4) var(--sp-6) 0; }
.inbox-invite:empty { padding: 0; }
.inbox-invite .banner { margin-inline: 0; }
.invite-card {
  border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--s2); padding: var(--sp-4) var(--sp-5);
  display: flex; flex-direction: column; gap: var(--sp-4);
}
.invite-head { display: flex; align-items: flex-start; gap: var(--sp-4); font-size: var(--fs-sm); }
.invite-head .grow { flex: 1; min-width: 0; }
.invite-head .hint { margin-top: 2px; }
/* Wraps rather than scrolls: on a narrow window the lifetime picker dropping
   below the button is fine, a horizontal scrollbar under the composer is not. */
.invite-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-4); }
.invite-actions .field { width: auto; min-width: 8rem; }
.invite-actions .field.sm { padding: 6px var(--sp-5); min-height: 32px; font-size: var(--fs-base); }

/* กล่องข้อความลูกค้า (inbox.js) — a conversation row is a <button>, because it
   opens a thread inside the console rather than navigating, and a <div> with a
   click handler is unreachable by keyboard. It has to be told to look like the
   `<a class="row">` next to it. */
.list > button.row {
  appearance: none; background: none; border: 0; font: inherit; color: inherit;
  cursor: pointer;
}

/* ---------- search filter chips (search.js) ----------
   `button.chip.pick` above is scoped to <button> because the composer's series
   chip opens a picker in place. A search filter is a DESTINATION — `#/search?
   q=…&k=people` — so it is an <a>: the back button, middle-click and "copy link
   address" all work, and the URL stays the whole state of the screen. Same
   affordance, different element, so the rules are restated rather than the
   button ones widened (widening them would drag every existing .chip.pick into
   an anchor's default underline handling). */
a.chip.pick {
  border: 1px solid var(--line); cursor: pointer; text-decoration: none;
}
a.chip.pick:hover { border-color: color-mix(in srgb, var(--a2) 40%, var(--line)); color: var(--ink); }
a.chip.pick.on    { color: var(--a2); border-color: color-mix(in srgb, var(--a2) 50%, var(--line)); }

/* ── #hashtag page (app/tagpage.js) ───────────────────────────────────────────
   Deliberately six rules. The page is built out of the ladder that already
   exists — .subhead · .section-label · .segs · .list.cards · .row · .chip ·
   .state — and the ONE thing it needs that nothing else has is a comment row
   whose lines stack.

   `.row .l1` is `white-space: nowrap` + ellipsis, which is right for a
   directory row (one fact, one line) and wrong for a comment: there the body IS
   the content, and the line under it — "ในโพสต์ของ …" — is what gives the
   sentence a subject. Dropping either to an ellipsis would leave a tag page of
   context-free fragments. Same override `.row.eprow` already makes for the
   series contents, for the same reason. */
.row.tagcomment .l1, .row.tagcomment .l2 { display: block; }
.row.tagcomment .l1 {
  white-space: pre-wrap; font-weight: 400; overflow: hidden;
  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical;
}
.row.tagcomment .l2 { margin-top: 2px; }
.row.tagcomment .l2 a { color: var(--dim); text-decoration: none; }
.row.tagcomment .l2 a:hover { color: var(--ink); text-decoration: underline; }

/* The table QR inside a sheet. `.qr-frame` is the QR-login frame, reused verbatim
   so there is one white QR card in the product rather than two — all this adds is
   centring it inside `.editor`'s column, which the login page got from its own
   `.qr-state`. No second QR style, no second size. */
.editor > .qr-frame { margin-inline: auto; }

/* ---------------------------------------------------------------------------
   Media references that are still resolving, or that could not be resolved.

   `media.js` swaps a `storage://` / `korat-private://` src for a transparent
   pixel the moment it claims the element, so the browser never paints its
   broken-image icon — and these two classes are what turns that pixel into an
   honest state instead of an invisible hole. A failure is NOT drawn as empty
   (CLAUDE.md): `[data-media-error]` keeps the three outcomes apart, the exact
   reason is on the element's `title`, and forbidden is deliberately the calmest
   of the three because it is a rule, not a fault.
   --------------------------------------------------------------------------- */
/* `--surface2` is a BIZ-console token (biz.css maps it to `--bz-surface2`); it
   does not exist in this stylesheet, so these two rules always took the literal
   fallback — a dark #1b1b2e placeholder behind every loading/failed image, in
   the LIGHT theme too. The fallback is now the house ladder's own rung. */
img.media-pending {
  background: var(--surface2, var(--s3));
  animation: media-pulse 1.2s ease-in-out infinite;
}
@keyframes media-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
img.media-failed {
  background: var(--surface2, var(--s3)) center / 40% no-repeat;
  border: 1px solid var(--line);
}
img.media-failed[data-media-error="forbidden"] {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238A86A9' stroke-width='1.8' stroke-linecap='round'%3E%3Crect x='4' y='10.5' width='16' height='10' rx='2'/%3E%3Cpath d='M8 10.5V7.5a4 4 0 0 1 8 0v3'/%3E%3C/svg%3E");
}
img.media-failed[data-media-error="missing"],
img.media-failed[data-media-error="malformed"] {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238A86A9' stroke-width='1.8' stroke-linecap='round'%3E%3Crect x='3.5' y='4.5' width='17' height='15' rx='2.5'/%3E%3Cpath d='m4 16 5-4 4 3 3-2 4 3'/%3E%3Cpath d='m3 3 18 18'/%3E%3C/svg%3E");
}
img.media-failed[data-media-error="network"] {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23F59E0B' stroke-width='1.8' stroke-linecap='round'%3E%3Cpath d='M12 4v9'/%3E%3Ccircle cx='12' cy='17.5' r='.9' fill='%23F59E0B'/%3E%3C/svg%3E");
}

/* ---------------------------------------------------------------------------
   Profile tabs (posts / albums / series / personal info) — profile.js
   --------------------------------------------------------------------------- */
.profile-tabs { margin-top: var(--sp-4); }

/* Albums — album.js. Grid of covers, then a photo grid once one is open. */
.album-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  gap: var(--sp-4); padding: 0 16px 8px;
}
.album-card {
  display: flex; flex-direction: column; gap: 4px; text-align: start;
  font: inherit; color: var(--ink); background: none; border: none; padding: 0; cursor: pointer;
}
.album-cover {
  width: 100%; aspect-ratio: 1; border-radius: var(--r-md); background: var(--s3);
  overflow: hidden;
}
.album-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.album-meta { display: flex; flex-direction: column; gap: 1px; }
.album-meta .l1 {
  font-size: var(--fs-row); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.album-meta .l2 { font-size: var(--fs-micro); color: var(--dim); }

.album-back {
  display: inline-flex; align-items: center; gap: 4px; margin: 0 16px 8px;
}
.album-back svg { width: 18px; height: 18px; }

.album-head { padding: 0 16px 8px; display: flex; flex-direction: column; gap: var(--sp-3); }
.album-head .row { align-items: center; gap: var(--sp-3); }
.album-head .l1 { font-size: var(--fs-h); font-weight: 700; }
.album-head .l2 { font-size: var(--fs-sm); color: var(--dim); }
.album-head [data-addbtn] { align-self: flex-start; cursor: pointer; }
.album-head .hint { margin: 0; font-size: var(--fs-micro); color: var(--dim2); }

.album-photogrid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: var(--sp-3); padding: 0 16px 16px;
}
.album-photo { position: relative; aspect-ratio: 1; border-radius: var(--r-sm); overflow: hidden; background: var(--s3); }
.album-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.album-photo-del {
  position: absolute; top: 4px; right: 4px; width: 22px; height: 22px; border-radius: 50%;
  background: color-mix(in srgb, #000 55%, transparent); color: #fff; border: none;
  font-size: 13px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.album-photo-del:hover { background: color-mix(in srgb, var(--red) 65%, #000 35%); }

/* Series tab — series.js. Same `.row` shape the rest of the app uses for a
   list of tappable cards, with a delete button beside it for the owner. */
.row.seriescard { align-items: center; gap: var(--sp-3); }
.row.seriescard [data-open] {
  display: flex; flex-direction: column; gap: 2px; text-align: start;
  font: inherit; color: var(--ink); background: none; border: none; padding: 0; cursor: pointer;
}
.row.seriescard .l1 { font-weight: 600; }
.row.seriescard .l2 { font-size: var(--fs-micro); color: var(--dim); }

/* ==========================================================================
   Three screens the owner named as "ยังดูเหมือนเว็บหัดทำ" — #/me, #/id and the
   shop console's landing page. Everything below is NEW, scoped class names:
   nothing here re-opens a shared rule, so no other screen can change shape
   because of this block.

   The single idea running through all three: every screen gets ONE object that
   is deliberately beautiful (a hero), and everything under it keeps the house
   rhythm — gutter var(--sp-6), block gap var(--sp-5), card radius var(--r-lg).
   A page that is a flat grey list from top to bottom reads as unfinished even
   when every row in it is correct.
   ========================================================================== */

/* ---------------------------------------------------------------------------
   The hero card — one shape, three screens (#/me's head, #/id's current
   handle, the console's summary). Accent-tinted surface, never a painted
   accent block: CLAUDE.md's rule is that surfaces do not wear the gradient,
   so this is the accent at 10-14% ON the surface ladder, which survives both
   themes because --s1 flips with the theme and the tint is a color-mix onto it.
   --------------------------------------------------------------------------- */
.hero-card {
  position: relative;
  overflow: hidden;
  /* No `margin-inline` of its own: the PAGE owns the horizontal gutter (see
     `.view.koratid` at the end of this file). A hero that carried its own 16px
     would have doubled to 32 the moment the column took responsibility. */
  padding: var(--sp-6);
  border-radius: var(--r-lg);
  border: 1px solid color-mix(in srgb, var(--a2) 26%, var(--line));
  background:
    radial-gradient(120% 140% at 100% 0%, color-mix(in srgb, var(--a1) 16%, transparent), transparent 62%),
    radial-gradient(120% 140% at 0% 100%, color-mix(in srgb, var(--a3) 14%, transparent), transparent 60%),
    var(--s1);
  box-shadow: var(--card-shadow);
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
}
/* The motif that makes it read as one designed object rather than a tinted box:
   a single soft ring bleeding off the top-inline-end corner. Pure CSS, no asset,
   and `pointer-events: none` so it never eats a tap meant for the content. */
.hero-card::after {
  content: "";
  position: absolute;
  inset-inline-end: -40px; top: -56px;
  width: 168px; height: 168px; border-radius: 50%;
  background: color-mix(in srgb, var(--a2) 12%, transparent);
  pointer-events: none;
}
.hero-card > * { position: relative; z-index: 1; }
.hero-card .hero-cap {
  font-size: var(--fs-sm); font-weight: 600; color: var(--dim);
  letter-spacing: .01em;
}
.hero-card .hero-val {
  font-size: var(--fs-hero); font-weight: 800; line-height: 1.2;
  overflow-wrap: anywhere;
}
.hero-card .hint { margin-top: var(--sp-2); }
.hero-card h1, .hero-card h2, .hero-card h3, .hero-card p { margin: 0; }

/* ==========================================================================
   #/id — Korat ID
   ========================================================================== */

/* The page's own rhythm. `.view` is a plain block elsewhere, so the gaps here
   used to be whatever each child's margin happened to be — one shared value
   instead. */
.view.koratid { display: flex; flex-direction: column; gap: var(--sp-5); }
/* A `.section-label` immediately after a paragraph already carries its own top
   padding; without this the two stack into an accidental double gap. */
.view.koratid > .section-label { padding-top: 0; }
/* `.card` carries `margin-block: 6px` of its own, which ADDS to the column's
   gap — three cards then sit 24/24/12 apart depending on what is beside them.
   The gap is the single source of vertical rhythm on this page. */
.view.koratid .card { margin-block: 0; }

/* The handle itself is the one thing on this page a person came to see. Latin,
   tabular, and big — with `dir="ltr"` already on the <bdi> in the markup. */
.koratid-handle {
  font-size: var(--fs-hero); font-weight: 800; line-height: 1.15;
  overflow-wrap: anywhere;
}
.koratid-handle.unset { font-size: var(--fs-title); font-weight: 700; color: var(--dim); }

/* Price table: length on the start edge, price on the end edge, both on one
   baseline. `.row`'s own `.l2` is a secondary LINE, not an end-aligned value —
   a price hanging off the left under the length was the reason the table read
   as a paragraph list rather than a price list. */
/* (The `.koratid-tier` rules live at the end of this file, with the wrapping
   fix they had to be rewritten for — a condition is now a chip, not a price
   with a `.cond` modifier, because the plan NAME moved to the row's own second
   line where it has room to wrap between words.) */

/* The verdict. It was `.hint good|warn`: coloured text on the page background,
   i.e. the single most important answer on the screen drawn as the least
   important thing on it. Now a real box, with the tone on the edge and the
   server's own sentence at full contrast. */
.hint.verdict {
  color: var(--ink);
  font-size: var(--fs-row);
  display: flex; flex-direction: column; gap: var(--sp-2);
  padding: var(--sp-5); border-radius: var(--r-md);
  border: 1px solid var(--line);
  border-inline-start: 3px solid var(--dim2);
  background: var(--s2);
}
.hint.verdict strong { font-size: var(--fs-title); font-weight: 800; }
.hint.verdict > div:not(:first-child) { color: var(--dim); font-size: var(--fs-sm); }
.hint.verdict.good {
  border-color: color-mix(in srgb, var(--green) 34%, var(--line));
  border-inline-start-color: var(--green);
  background: color-mix(in srgb, var(--green) 9%, var(--s1));
}
.hint.verdict.good strong { color: var(--green); }
.hint.verdict.warn {
  border-color: color-mix(in srgb, var(--amber) 34%, var(--line));
  border-inline-start-color: var(--amber);
  background: color-mix(in srgb, var(--amber) 9%, var(--s1));
}
.hint.verdict.warn strong { color: var(--amber); }

/* The name box. The input and its button are the primary action of the whole
   screen, so the button is filled and both are the same height — a ghost button
   half a line shorter than the field beside it was the "หัดทำเว็บ" tell. */
.koratid-check { display: flex; gap: var(--sp-3); align-items: stretch; }
.koratid-check .field { flex: 1; min-width: 0; }
.koratid-check .btn { flex: 0 0 auto; }
/* The claim button is the primary action of the screen and it appears only once
   the name really is free — full width, with the verdict directly above it, so
   "ชื่อนี้ว่าง" and the button that takes it read as one moment. */
.koratid-act:not(:empty) { margin-top: var(--sp-4); }

/* ==========================================================================
   #/me — my profile
   ========================================================================== */

/* The head is a card at EVERY width now. Below 640 it was bare on the page
   background: the avatar, name and bio floated with nothing holding them, and
   the phone is the width the owner actually reads on. */
@media (max-width: 639px) {
  .profile-head {
    margin: var(--sp-4) var(--sp-6) 0; padding: var(--sp-6); gap: 10px;
    background: var(--s1); border: 1px solid var(--line);
    border-radius: var(--r-lg); box-shadow: var(--card-shadow);
  }
  .profile-head .av { border-color: var(--s1); }
  /* …but the EDIT screen keeps "head and form are one object" (the rule at
     ~3216 does this from 640 up). Carding the head on a phone would have put a
     bordered box directly above a bare form again — the exact split that rule
     was written to remove. Same treatment, phone margins. */
  .profile-editor {
    display: block; margin: var(--sp-4) var(--sp-6) 0;
    background: var(--s1); border: 1px solid var(--line);
    border-radius: var(--r-lg); box-shadow: var(--card-shadow); overflow: hidden;
  }
  .profile-editor > .profile-head,
  .profile-editor > .form {
    margin: 0; max-width: none;
    background: transparent; border: 0; border-radius: 0; box-shadow: none;
  }
  .profile-editor > .form { border-top: 1px solid var(--line); padding: var(--sp-6); }
}
/* No cover uploaded is the common case, and a flat 14%-tint band is the most
   obviously "unstyled" thing on the screen. Two soft accent pools + a hairline
   make it read as intentional artwork instead of a missing image. An <img>,
   when there is one, covers all of it. */
.profile-head .cover {
  background:
    radial-gradient(90% 120% at 18% 0%, color-mix(in srgb, var(--a1) 34%, transparent), transparent 62%),
    radial-gradient(90% 120% at 82% 100%, color-mix(in srgb, var(--a3) 30%, transparent), transparent 62%),
    var(--brand-soft);
}
/* The handle is the person's address, so it is an object you could copy, not a
   third grey line. Chipped inside the `.handle` line the nickname shares, which
   keeps the one-line grouping the 2026-07-28 fix put there on purpose. */
.profile-head .handle bdi {
  display: inline-block;
  padding: 1px var(--sp-4);
  border-radius: var(--r-pill);
  background: var(--s2);
  border: 1px solid var(--line);
  color: var(--ink);
  font-size: var(--fs-base);
  font-weight: 600;
}
.profile-head .bio { margin: 0; }
/* One primary action, full width on a phone: "แก้ไขโปรไฟล์" was a 120px pill
   adrift in a centred row. */
@media (max-width: 639px) {
  .profile-head .profile-acts .btn { flex: 1 1 auto; }
}
/* The tab strip is the boundary between "who this is" and "what they made" —
   it needs its own air above, and it must not be able to push the page sideways
   on a narrow phone. */
.profile-tabs { margin-top: var(--sp-5); }
.profile-tabs > .segs { overflow-x: auto; flex-wrap: nowrap; scrollbar-width: none; }
.profile-tabs > .segs::-webkit-scrollbar { display: none; }

/* ==========================================================================
   #/work/<id> — the shop console's landing page on koratland.com/app
   (`renderDashboardApp`). The business host draws `renderDashboardBiz` from
   biz.css instead; these class names exist in both, which is why every rule
   here is qualified by a class app.css owns.
   ========================================================================== */

.view.dash { display: flex; flex-direction: column; gap: var(--sp-5); }

/* Two columns on a phone, three from 640 — `.stats`' bare `repeat(3, …)` put
   three 100px boxes across a 360px screen and the numbers wrapped inside them. */
.stats.dash-grid {
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sp-4);
  padding-inline: var(--sp-6);
}
@media (min-width: 640px) {
  .stats.dash-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

/* The numbers ARE the page. Each card carries one accent from the app's own
   three-stop brand, in a fixed order, so a reader learns "the middle one is
   always orders". Accent-derived on purpose — these are not status colours. */
.stat.dash-card {
  --dash-c: var(--a2);
  position: relative;
  overflow: hidden;
  min-height: 104px;
  padding: var(--sp-5);
  border-radius: var(--r-lg);
  border: 1px solid color-mix(in srgb, var(--dash-c) 26%, var(--line));
  background:
    radial-gradient(120% 150% at 100% 0%, color-mix(in srgb, var(--dash-c) 15%, transparent), transparent 64%),
    var(--s1);
  box-shadow: var(--card-shadow);
  display: flex; flex-direction: column; gap: var(--sp-2);
}
.stats.dash-grid > .dash-card:nth-child(3n+1) { --dash-c: var(--a1); }
.stats.dash-grid > .dash-card:nth-child(3n+2) { --dash-c: var(--a2); }
.stats.dash-grid > .dash-card:nth-child(3n+3) { --dash-c: var(--a3); }
.stat.dash-card::after {
  content: "";
  position: absolute; inset-inline-end: -30px; top: -38px;
  width: 108px; height: 108px; border-radius: 50%;
  background: color-mix(in srgb, var(--dash-c) 10%, transparent);
  pointer-events: none;
}
.stat.dash-card > * { position: relative; z-index: 1; }
.stat.dash-card .cap {
  font-size: var(--fs-sm); font-weight: 600; color: var(--dim); line-height: 1.4;
}
.stat.dash-card .val {
  font-size: var(--fs-h); font-weight: 800; line-height: 1.25;
  margin-top: auto; overflow-wrap: anywhere;
}
@media (min-width: 640px) { .stat.dash-card .val { font-size: var(--fs-hero); } }
/* A money line can be two currencies on two lines, and "โหลดไม่สำเร็จ" is a
   sentence — neither may be set at hero size or the card grows a scrollbar. */
.stat.dash-card .val .err,
.stat.dash-card .val .dim { font-size: var(--fs-base); font-weight: 600; }
.stat.dash-card .val .err { color: var(--red); }
.stat.dash-card .val .val-link { color: var(--dash-c); text-decoration: none; }
.stat.dash-card .val .val-link:hover { text-decoration: underline; }
.stat.dash-card .sub { font-size: var(--fs-micro); color: var(--dim2); }
/* `.skel-inline` is emitted by work.js (and koratid/premium) on every consumer
   host and had NO rule in this file at all — an inline skeleton with no width
   and no height is a zero-pixel box, so "กำลังโหลด" on these cards was drawn as
   nothing whatsoever. biz.css defines it for the business host (line ~492);
   this is the same shape for koratland.com/app. */
.skel-inline {
  display: inline-block; vertical-align: middle;
  width: 72px; height: 16px; border-radius: var(--r-sm);
  background: var(--s3);
}
.stat.dash-card .skel-inline { margin-top: auto; }

/* "สิ่งที่ต้องทำ" is the reason a shop owner opens this screen, so it is the
   one amber object above the numbers — and, crucially, it DISAPPEARS when it is
   empty. `todoHost` is a `.panel` that exists from first paint and gets emptied
   when there is nothing to do, which drew a bordered empty box at the top of a
   healthy shop's dashboard. */
.panel.dash-todo {
  margin-inline: var(--sp-6);
  border-color: color-mix(in srgb, var(--amber) 38%, var(--line));
  background: color-mix(in srgb, var(--amber) 8%, var(--s1));
  gap: var(--sp-2);
}
.panel.dash-todo:empty { display: none; }
.panel.dash-todo h3 { font-size: var(--fs-base); color: var(--amber); }
.panel.dash-todo > .list { padding: var(--sp-4) 0 0; gap: var(--sp-3); }
.panel.dash-todo .row { background: color-mix(in srgb, var(--amber) 10%, var(--s2)); }
.panel.dash-todo .row .grow {
  font-size: var(--fs-row); font-weight: 500; white-space: normal; line-height: 1.45;
}
/* A todo row leads somewhere; say so at the end edge rather than leaving an <a>
   that looks exactly like a paragraph. */
.panel.dash-todo .row::after {
  content: "›"; flex: 0 0 auto; color: var(--amber); font-size: var(--fs-title); line-height: 1;
}
html[dir="rtl"] .panel.dash-todo .row::after { content: "‹"; }

/* ==========================================================================
   ROOT CAUSE — "ข้อความชิดขอบซ้ายขวา" / "แถบหัวกินเต็มความกว้างจอ"

   `.section-label.row` (album.js, series.js, profile.js, resume.js×2, tag.js)
   was declared as `display:flex; align-items:center; gap:8px` and nothing else,
   so it took `.row`'s SURFACE — `background: var(--s2)`, its border and its
   hover — while `.section-label`, declared later in this file, won the `padding`
   cascade with `padding-inline: var(--sp-6)`.

   The result is a bar whose BACKGROUND runs edge to edge of the screen while
   its TEXT sits 16px in. Next to a profile head and a `.segs` strip that are
   both inset by MARGIN, it reads as a stripe from a different page — which is
   exactly what the owner saw on the ซีรีส์ tab.

   It is a HEADING with an action on its end edge. It is not a row: it is not
   tappable, it leads nowhere, and it must not wear a surface. Fixed here, once,
   for all six call sites rather than patched per screen.
   ========================================================================== */
.section-label.row {
  display: flex; align-items: center; gap: var(--sp-4);
  /* Restated rather than inherited: `.row` and `.section-label` have equal
     specificity and the winner is decided by source order, which is not a
     thing the next reader should have to work out. */
  padding: var(--sp-5) 0 var(--sp-1);
  padding-inline: var(--sp-6);
  background: transparent;
  border-color: transparent;
  border-radius: 0;
  cursor: default;
}
.section-label.row:hover { border-color: transparent; background: transparent; }
.section-label.row .grow { flex: 1; min-width: 0; }
/* The action keeps its own size and never stretches with the heading. */
.section-label.row .btn,
.section-label.row .linkbtn { flex: 0 0 auto; }
/* A heading's action sits ON the heading baseline, so it drops the section
   label's own top padding — otherwise the button hangs below the word. */
.section-label.row .btn.sm { padding-block: var(--sp-3); }

/* ==========================================================================
   #/me — the four tabs. The head is a card with 16px margins; everything below
   the tab strip has to live on that same grid or the page reads as two screens
   glued together.
   ========================================================================== */

/* The tab strip: `.segs`' selected state is a solid --a2 pill, which against
   three dim labels on a --s2 track is the loudest thing on the screen — louder
   than the person's own name. Softened to the accent FILM (--sel) with accent
   text: still unmistakably "you are here", no longer a block of colour. The
   token is the app's own selection tint, used everywhere else for exactly this.
   Scoped to `.profile-tabs` so no other segmented control changes. */
.profile-tabs > .segs {
  background: var(--s1);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  padding: var(--sp-2);
  gap: var(--sp-2);
}
.profile-tabs > .segs button { border-radius: var(--r-pill); padding: var(--sp-3) var(--sp-5); }
.profile-tabs > .segs button[aria-selected="true"] {
  background: var(--sel); color: var(--a2); font-weight: 700;
}
.profile-tabs > .segs button:hover[aria-selected="false"] { background: var(--hover); }

/* An empty tab is a real answer, not a hole. `.state` is centred text on the
   page background, so on a screen whose every other block is a bordered card it
   reads as "the list failed to draw". A dashed card says "this is intentionally
   empty" — the one border style in the app that means "nothing here YET", which
   is precisely the difference between an empty state and a failed one. The
   FAILED box keeps its solid red-headline treatment untouched below. */
.profile-tabs .state {
  margin: var(--sp-4) var(--sp-6) 0;
  padding: var(--sp-8) var(--sp-7);
  border-radius: var(--r-lg);
  background: var(--s1);
  border: 1px dashed var(--line);
}
.profile-tabs .state.failed {
  border-style: solid;
  border-color: color-mix(in srgb, var(--red) 34%, var(--line));
}
.profile-tabs .state .icon { font-size: 30px; }
/* The tab column needs a floor, or a one-row list leaves the viewport half
   empty under it and the screen looks unfinished. This is the height the empty
   state occupies, so a short list and an empty tab end at the same place. */
.profile-tabs > [data-tabcontent] { min-height: 220px; padding-bottom: var(--sp-5); }

/* Series / album rows: `.row`'s 7/10 density is right for a contact list, not
   for a card whose second line is a description that has to breathe. */
.row.seriescard {
  padding: var(--sp-5); gap: var(--sp-4);
  background: var(--s1); border-color: var(--line);
}
.row.seriescard:hover { border-color: color-mix(in srgb, var(--a2) 40%, var(--line)); }
.row.seriescard [data-open] { gap: var(--sp-2); }
.row.seriescard .l1 { font-size: var(--fs-row); font-weight: 600; }
.row.seriescard .l2 { font-size: var(--fs-sm); color: var(--dim); white-space: normal; }

/* ==========================================================================
   Destructive buttons must have a SHAPE.

   `.btn.danger` sets `border: 1px solid transparent` — an invisible outline —
   and it is declared AFTER `.btn.ghost`, so `class="btn ghost sm danger"` (the
   ซีรีส์ delete) lost the ghost's real border and rendered as bare red words
   floating at the end edge. A destructive control with no boundary is the
   worst one to leave shapeless: it is the button people need to be sure they
   are not about to hit by accident.

   Quiet AND bounded — a red outline, not a red fill. It must not out-shout the
   thing it deletes.
   ========================================================================== */
.btn.ghost.danger {
  border: 1px solid color-mix(in srgb, var(--red) 38%, var(--line));
  color: var(--red);
  background: transparent;
}
.btn.ghost.danger:hover {
  border-color: var(--red);
  background: color-mix(in srgb, var(--red) 12%, transparent);
}

/* ==========================================================================
   LIGHT-THEME FIELDS — fixed at the standard, not on one screen.

   `:root[data-theme="light"] .field { background: var(--s3) }` (line ~3118)
   was written for a real reason — the light theme sets --s1 AND --s2 both to
   #FFFFFF, so the original `background: var(--s2)` made a white field on a
   white card, i.e. no field at all. But --s3 in light is #EBE9F5, a LAVENDER
   grey, and a filled lavender block sitting in a white card is what the owner
   saw on #/id: not a text box, a coloured slab.

   The mainstream answer is the one this file already uses for cards: white
   surface, real border. So the fill goes back to the surface rung and the
   EDGE does the work — derived from --ink, so it is a neutral grey that
   follows the theme rather than a hand-picked hex, and it cannot carry the
   accent's purple cast the way --s3 does.

   ★ BLAST RADIUS: every `.field` in the web app, light theme only. That is
   deliberate and was the instruction — one standard, not a per-page patch.
   Dark theme is untouched (--s2 #17172A already reads against --s1 #12121F).
   `label.field` (the wrapper variant, ~3514) inherits this by the same
   selector, so the two stay identical.
   ========================================================================== */
@media (prefers-color-scheme: light) {
  :root[data-theme="auto"] .field {
    background: var(--s1);
    border-color: color-mix(in srgb, var(--ink) 22%, transparent);
  }
  :root[data-theme="auto"] .field:hover {
    border-color: color-mix(in srgb, var(--a2) 55%, transparent);
  }
}
:root[data-theme="light"] .field {
  background: var(--s1);
  border-color: color-mix(in srgb, var(--ink) 22%, transparent);
}
:root[data-theme="light"] .field:hover {
  border-color: color-mix(in srgb, var(--a2) 55%, transparent);
}
/* Focus has to be unmistakable once the resting state is quiet — the accent
   border plus the app's own focus ring, in BOTH themes, stated here so the
   calmer border above never reads as "this box is disabled". */
.field:focus,
.field:focus-visible { border-color: var(--a2); }

/* ==========================================================================
   #/id — the gutter, owned by the CONTAINER.

   Every block on this page used to carry its own `margin-inline: var(--sp-6)`
   or `padding-inline: var(--sp-6)` inline, and the ones nobody remembered —
   the premium section's heading and paragraph, the billing rows, the invoice
   cards — got nothing and ran into both edges of the screen. A gutter that is
   re-declared per child is a gutter that will be missing from the next child
   somebody adds.

   So the COLUMN holds it, once, and every descendant inherits it. The inline
   margins have been deleted from koratid.js in the same change — leaving them
   would have doubled to 32px, which is the other half of this bug.
   ========================================================================== */
.view.koratid { padding-inline: var(--sp-6); }
/* Children that ship their own gutter must give it up, or they indent twice.
   `.section-label` is the big one: it is 16px by default and appears at four
   levels of nesting on this page (page headings, the premium section's
   heading, "ใบแจ้งหนี้ค้างชำระของคุณ" inside the billing card). */
.view.koratid .section-label { padding-inline: 0; }
.view.koratid .list { padding-inline: 0; }
/* …except a list INSIDE a card, where the card is the gutter holder and the
   rows must not touch its border. `.card` here is `padding: 0` on purpose so
   the list's own padding draws the inset. */
.view.koratid .card .list { padding-inline: var(--sp-6); }

/* The price table's own note sits inside the `padding: 0` card, so it needs
   the card's inset restated — it is the one child of that card that is not
   the list. Replaces an inline `padding-inline` that no longer belongs now
   that the column owns the gutter. */
.koratid-tiernote { padding: 0 var(--sp-6) var(--sp-5); margin-top: 2px; }
.koratid-tiers { overflow: hidden; }

/* ---- The end-edge cell must never wrap ---------------------------------
   `ต้องมีแผน "ชื่อผู้ใช้สั้นพิเศษ (3 ตัว)"` broke mid-token in a narrow cell on
   every row. The plan NAME now lives on the row's second line (koratid.js) and
   only a fixed short chip is left here, so this cell can be `nowrap` safely —
   in Thai, and in German and Russian, which are longer. */
.koratid-tier { align-items: center; min-height: 52px; }
.koratid-tier .grow { min-width: 0; }
/* The plan name is prose and wraps between words, with the length above it. */
.koratid-tier .l2 { white-space: normal; overflow: visible; line-height: 1.45; }
/* The end-edge cell moved to `.pricecol` (end of file): pinning to the edge
   with `margin-inline-start: auto` was only half the job — with no fixed WIDTH
   two rows whose values differ in length still began at different x, which is
   the alignment complaint. Deleted here so the shared rule is the only one. */

/* ==========================================================================
   BILLING CARDS — one shell for the entitlement card AND the invoice card
   (koratid_premium.js `billCard()`), on #/id under "ซื้อ Korat ID ระดับพรีเมียม".

   What was wrong: both were `.row` — the app's LIST-ROW density — bent into
   shape with two different sets of inline styles. The invoice one set
   `flex-direction: column` and then appended one <div> per button, so
   "แนบสลิป" and "ยกเลิกคำสั่งซื้อนี้" stacked VERTICALLY, each shrink-wrapped
   to its own text and flush left. That reads as a list of links, not as a
   card's controls, and it made the card taller than its content needs.

   The shell is four bands, and every card in this section uses all four:
     .bill-head  identity on the start edge, value + status on the end edge
     .bill-acts  the ACTION ROW — horizontal, separated by a real rule
     .bill-msg   full-width prose under the buttons (results, refusals)
   A card with 0, 1 or 2 buttons has to look deliberate in all three cases,
   which is why `.bill-acts:empty` removes itself AND its rule.
   ========================================================================== */
.card.bill-card {
  padding: var(--sp-5);
  gap: var(--sp-4);
}

/* ---- head: who / what, and the number ---------------------------------- */
.bill-head {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: var(--sp-5);
}
.bill-ident { min-width: 0; flex: 1 1 auto; display: flex; flex-direction: column; gap: 2px; }
.bill-title {
  font-size: var(--fs-row); font-weight: 700; color: var(--ink);
  line-height: 1.4; overflow-wrap: anywhere;
}
.bill-sub { font-size: var(--fs-sm); color: var(--dim); line-height: 1.5; overflow-wrap: anywhere; }
/* The ring swatch, previously six inline declarations repeated in the JS. */
.bill-dot {
  display: inline-block; width: 10px; height: 10px; border-radius: var(--r-pill);
  vertical-align: middle; margin-inline-end: var(--sp-2);
  border: 1px solid var(--line);
}
/* The amount is a VALUE and must read as one. It used to be glued to the plan
   name with an em dash (`ชื่อแผน — ฿4,900.00`), which reads as part of the
   title. End-aligned, tabular, and the status chip sits under it rather than
   floating on a line of its own below the whole card. */
.bill-value {
  flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end;
  gap: var(--sp-3); text-align: end;
}
.bill-amount {
  font-size: var(--fs-title); font-weight: 800; color: var(--ink);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.bill-value .chip { white-space: normal; text-align: center; max-width: 168px; }

/* ---- the action row ----------------------------------------------------
   HORIZONTAL is the default, because that is what the owner asked for on both
   clients today — the web and the Android app say the same thing now.

   `flex-wrap: wrap` + `min-width` on the buttons is the narrow-screen answer:
   at 320px the two Thai labels stop fitting side by side and each takes its
   own full-width line, in order, still as buttons. That is a deliberate wrap,
   not text squeezed until it is unreadable — the labels themselves never
   compress, because `min-width` sets the floor at which wrapping happens
   instead. German and Russian wrap earlier and behave identically. */
.bill-acts {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3);
  padding-top: var(--sp-4);
  border-top: 1px solid var(--line);
}
/* A card with no buttons at all (paid, or awaiting review) must not keep the
   rule — an empty bordered strip reads as something that failed to render.
   `:empty` alone is NOT enough and that is the trap: the cancel button's slot
   span is appended before the server has answered, so the row always has a
   child node and is never `:empty`. It is the presence of a BUTTON that
   decides. `:has()` is already used in this file (`.form:has(> .fgroup)`,
   `label.field:has(> input[type="checkbox"])`), so this needs no new support. */
.bill-acts:empty,
.bill-acts:not(:has(.btn)) { display: none; }
/* The cancel button arrives asynchronously in its own slot, so the slot is
   `display: contents` — it must not become a flex item of its own, or the two
   buttons would be laid out as "button" + "box containing a button" and stop
   sharing the row evenly. It is also why `:empty` above still works: an empty
   span with `display: contents` boxes nothing. */
.bill-actslot { display: contents; }
.bill-acts .btn {
  flex: 1 1 auto;
  /* 120 = the widest of the section's own labels at --fs-base without
     compressing; two of them plus the 6px gap fit inside a 320px screen once
     the 16px page gutter and the card's 12px padding are taken off (246 < 264). */
  min-width: 120px;
}

/* ---- messages: full width, under the buttons --------------------------- */
.bill-msg { display: flex; flex-direction: column; gap: var(--sp-3); }
.bill-msg:empty { display: none; }
.bill-msg > div:empty { display: none; }
.bill-msg .hint { margin: 0; }

/* ==========================================================================
   OVERLAYS THAT MUST FIT — the class-wide rule, not one menu's patch.

   The bug: the account menu has 17 items. `openMenu()` positioned it with
   `top = min(anchor.bottom + 6, innerHeight - menu.offsetHeight - 8)` and this
   file gave `.menu` no `max-height` and no `overflow`. On a phone the menu is
   taller than the window, so that expression evaluates NEGATIVE and the menu
   was placed above the top of the screen — its `@handle` header sitting behind
   the browser's own URL bar, invisible and untappable, and unreachable because
   a `position: fixed` box does not scroll with the page.

   Two halves, and BOTH are needed:
     · JS  `placeAnchored()` (ui.js) clamps the top edge into the visible band
           and sets `max-height` from what is left, measuring the VISUAL
           viewport so the URL bar and the keyboard count.
     · CSS everything below: the overlay must actually be able to scroll inside
           the height JS gives it, and the ones with no JS placement at all
           (dialog, sheet, lightbox) get the same ceiling declaratively.

   Every overlay in the app, and where each one gets its ceiling:
     .menu       anchored dropdown (openMenu)        → JS + scroll rule below
     .notifpanel anchored panel (openNotifPanel)     → JS, same helper now
     .dialog     confirm/prompt (confirmDialog)      → CSS below (was unbounded)
     .sheet      bottom sheet (openSheet)            → already `max-height:88dvh`
     .lb         lightbox                            → `inset:0`, no list, fine
     .toast      transient                           → already clears tab bar
   ========================================================================== */

/* ---- anchored dropdown --------------------------------------------------
   `max-height` arrives from JS as an inline style; this is what lets the box
   USE it instead of simply overflowing past its own border. */
.menu {
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* A floor for the degenerate case (JS not yet run, or a landscape phone
     ~320px tall): never taller than the visible viewport, whatever happens. */
  max-height: var(--overlay-max-h);
}
/* The header identifies whose menu this is, so it is the one row that must
   survive scrolling to the bottom of 17 items — it is also the row that was
   hidden behind the URL bar, which is what made the bug unrecoverable. */
.menu .who {
  position: sticky; top: 0; z-index: 1;
  background: var(--s1);
  border-bottom: 1px solid var(--line);
  margin-bottom: var(--sp-2);
}

/* ---- the anchored notification panel ------------------------------------
   Same ceiling as a backstop; JS still sets the exact value. */
.notifpanel { max-height: var(--overlay-max-h); }

/* ---- centred dialogs ----------------------------------------------------
   `.scrim` is `inset: 0`, i.e. the LAYOUT viewport — the same 100vh trap in
   another spelling: with the URL bar expanded, its bottom is off-screen, so a
   tall `.dialog` centred in it is centred on a box taller than the screen and
   its buttons end up below the fold. `.dialog` also had no `max-height` at
   all, so a long confirm on a landscape phone had no way to scroll.

   `100dvh` + safe insets fixes the box; `max-height` + `overflow` fixes the
   dialog. The action row is pinned so "ยกเลิก / ยืนยัน" is reachable without
   scrolling to the end of a long explanation. */
/* `:not(.sheet-scrim)` is load-bearing, not tidiness: the bottom sheet reuses
   this same class (`class="scrim sheet-scrim"`, ui.js:415) and `.sheet-scrim`
   deliberately sets `padding: 0` so the sheet can sit flush on the bottom edge.
   That rule is declared EARLIER in this file, so an unqualified `.scrim` here
   would win on source order and put a 16px gap under every bottom sheet in the
   app. Height is safe to give both, and the sheet wants it too. */
/* `--vv-h` / `--vv-t` are written by `fitScrimToVisualViewport()` (ui.js) from
   `visualViewport`, because the on-screen keyboard and Chrome's autofill
   suggestion bar do NOT shrink `100dvh` — they cover it. A modal sized against
   `dvh` therefore puts its primary button (`ชำระ ฿…`) underneath that bar, which
   is exactly how a filled-in card could not be paid on the owner's phone.
   The fallbacks keep this identical to the old behaviour when the JS has not run
   yet, and on desktop the measured values ARE the window, so nothing moves. */
/* ★ `grid-template-rows` is the half that was missing, and without it the
   height above bought NOTHING. `.scrim` is `display: grid` (line ~1985), so
   `.sheet`/`.dialog` are GRID ITEMS and their `max-height: 100%` resolves
   against the grid AREA — not against the scrim. With the default single
   `auto` row, that area GROWS to the item's own content, so `100%` is
   whatever the sheet already wanted to be and the clamp is a no-op.
   Measured in headless Chrome at 390×844 with `--vv-h: 280px`:
     before → scrim 280px, sheet 666px, its buy button 917px below the band
     after  → scrim 280px, sheet 280px, body scrolls (scrollHeight 1154)
   That is the reported "sheet opens ~1/3 tall and is cut off": the sheet was
   not short, it was TALLER than the visible band and hanging off the bottom
   of it, so only its head showed. `minmax(0, 1fr)` pins the row to the scrim's
   own content box, which is exactly the band `--vv-h` measured. */
.scrim {
  height: var(--vv-h, 100dvh); top: var(--vv-t, 0px); bottom: auto;
  left: var(--vv-l, 0px); width: var(--vv-w, 100%); right: auto;
  grid-template-rows: minmax(0, 1fr);
}
.scrim:not(.sheet-scrim) {
  padding: max(var(--sp-6), var(--safe-t)) var(--sp-6) max(var(--sp-6), var(--safe-b));
}
/* Scrolls as ONE box rather than per-paragraph — `.dialog p` and `.dialog
   .keeps` scrolling independently would give a short dialog two tiny
   scrollbars and read as a bug. */
.dialog {
  max-height: 100%;
  overflow-y: auto;
  overscroll-behavior: contain;
}
/* A long explanation must never push the answer buttons out of reach: they
   stay on screen while the text above them scrolls under. */
.dialog .acts {
  position: sticky; bottom: 0;
  background: var(--s1);
  padding-top: var(--sp-4);
}

/* ---- bottom sheet -------------------------------------------------------
   `max-height: 88dvh` was already right (dvh, not vh). What it lacked was the
   home-indicator inset: on a gesture-nav phone the last row of a sheet sat
   under the system bar. The sheet is bottom-anchored, so only the bottom
   matters here. */
/* `min(…, 100%)`: 100% is the scrim, i.e. the band the reader can actually see.
   With the keyboard up, 88dvh is TALLER than that band, so the min() is what
   keeps the sheet inside it — and the sheet is a flex column whose body scrolls,
   so the content stays reachable instead of hanging below the fold. */
.sheet { max-height: min(calc(88dvh - var(--safe-b)), 100%); }

/* A primary action inside a sheet that also has fields: it stays on screen while
   the fields above it scroll under. Only sheets that opt in (`sheet-cta`) get
   this — a sticky button in a read-only sheet would just eat a row of space. */
.sheet-body .sheet-cta {
  position: sticky; bottom: 0; z-index: 1;
  background: var(--s1);
  padding-block: var(--sp-2);
  margin-top: var(--sp-2);
}
.sheet-body { padding-bottom: calc(var(--sp-6) + var(--safe-b)); overscroll-behavior: contain; }

/* ---- very short viewports (a phone held sideways, ~320-380px tall) -------
   Nothing here changes the numbers on a normal screen: it only stops the
   chrome from eating a window that has almost none to give. */
@media (max-height: 420px) {
  .menu .who { padding-block: var(--sp-3); }
  .menu :is(button, a[role="menuitem"]) { padding-block: var(--sp-3); }
  .scrim { padding-block: var(--sp-4); }
}

/* ==========================================================================
   "NAME ON THE LEFT, PRICE ON THE RIGHT" — one rule for every such row.

   ★ THE ROOT CAUSE ★
   There is NO global `.grow` rule in this file. `flex: 1; min-width: 0` comes
   only from `.row .grow` (line ~745) — and `.plan-list-row` is its own class,
   not a `.row`. So inside the plan picker the left column was a default flex
   item (`flex: 0 1 auto`, `min-width: auto`): it neither grew to push the
   price to the end edge, nor was allowed to shrink when the name was long.

   That single omission produced all three symptoms the owner named at once:
     · `฿390` and `฿1,490` landed at whatever x their short names ended at;
     · `฿4,900` sat mid-row, because "ชื่อผู้ใช้สั้นพิเศษ (3 ตัว)" could not
       shrink and shoved the price inward;
     · so no two prices shared a column and the eye could not run down them.
   `.plan-list-row .price small { display: block }` then dropped `/ปี` onto a
   second line, which is what made it read as broken rather than merely
   misaligned.

   The contract, applied everywhere the pattern appears:
     .pricecol      fixed-width column, pinned to the end edge, never wraps,
                    tabular figures so the digits line up down the page
     .pricecol-owner the row that contains one — its left column must be
                    allowed BOTH to grow and to shrink
   ========================================================================== */

.pricecol {
  flex: 0 0 auto;
  /* `auto` margin, not `justify-content`, so it pins to the end edge even when
     the left column has not been told to grow — belt and braces, because the
     next row type someone adds will forget again. */
  margin-inline-start: auto;
  min-width: var(--pricecol-w);
  text-align: end;
  /* The whole point: `฿4,900` and `/ปี` are ONE value and never break apart.
     The left column shrinks instead — it is the one with prose in it. */
  white-space: nowrap;
  /* Digits of equal width, so 390 / 1,490 / 4,900 stack with their units
     aligned down the column instead of drifting by a few pixels per glyph. */
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.35;
}
/* The period suffix stays ON the line, one step smaller and dimmer — a unit,
   not a second fact. (`display: block` is what put it on its own line.) */
.pricecol small,
.pricecol .unit {
  display: inline;
  font-weight: 400;
  font-size: var(--fs-micro);
  color: var(--dim2);
  margin-inline-start: 1px;
}
/* "ยังไม่เปิดขาย" is not a price: it keeps the column but drops the tabular
   figures and the amount's weight, so it cannot be misread as one at a glance.
   Amber is the app's literal "waiting on someone" status colour, replacing an
   inline `style="color:var(--amber)"` that said the same thing less portably. */
.pricecol-na {
  font-size: var(--fs-sm); font-weight: 600; color: var(--amber);
  white-space: normal;
}
/* A chip standing in for a price (the length table's "ต้องมีแผน" /
   "ไม่เปิดขาย") sits in the same column and right-aligns inside it. */
.pricecol > .chip { white-space: nowrap; }

/* ---- the rows that own one -------------------------------------------- */
/* Each of these is a container whose LEFT column must grow and shrink. Listed
   explicitly rather than as a bare `.grow` rule, because a global `.grow`
   would reach ~100 modules that were written against its absence. */
.plan-list-row > .grow,
.koratid-tier > .grow,
.bill-head > .bill-ident {
  flex: 1 1 auto;
  min-width: 0;
}
/* A long plan name wraps inside its own column and never pushes the price. */
.plan-list-row .l1 { min-width: 0; flex-wrap: wrap; overflow-wrap: anywhere; }
.plan-list-row .l2 { white-space: normal; overflow-wrap: anywhere; }

/* ---- the three sites, now all on the same contract --------------------- */
/* Plan picker sheet: replaces `.price { flex:0 0 auto; text-align:end }` plus
   the `small { display:block }` that split the unit off. */
.plan-list-row .price { font-size: var(--fs-row); }
/* Length table: the earlier `margin-inline-start:auto` on `.price`/`.chip`
   pinned them to the edge but gave no fixed WIDTH, so two rows whose values
   differ in length still started at different x. `.pricecol` supplies both. */
.koratid-tier .pricecol { display: inline-flex; justify-content: flex-end; align-items: center; }
/* Invoice card: the amount + status chip stack is the same column, so it gets
   the same floor. Without it, two invoice cards of different amounts had
   their numbers starting at two different x down the same list. */
.bill-value { min-width: var(--pricecol-w); }
.bill-amount { font-variant-numeric: tabular-nums; white-space: nowrap; }

/* ---- very narrow phones -------------------------------------------------
   At 320px a fixed 92px column plus a long German plan name gets tight. The
   column narrows in one step — every row narrows together, so the alignment
   the whole rule exists for is preserved; it is the NAME that gives way,
   which is the column with wrapping prose in it. */
@media (max-width: 359px) {
  :root { --pricecol-w: 78px; }
  .pricecol { font-size: var(--fs-base); }
}

/* ==========================================================================
   PREMIUM IDENTITY — the ring around an avatar, the badge beside a Korat ID,
   and the live preview in the plan sheet.

   ★ THIS IS THE FIRST IMPLEMENTATION, NOT A SECOND ONE ★
   Verified before writing a line (2026-08-13): nothing in this repo draws a
   premium ring or badge anywhere. `ring_color`/`ring_hex` appear only in
   koratid_premium.js and Android's KoratIdPremium.kt, and that Kotlin only
   prints the colour's NAME next to a 10dp swatch on the entitlement row. So
   there was no existing renderer to reuse — these rules ARE the renderer, and
   they live beside `.av` (ui.js's `ringedAvatarHtml` wraps `avatarHtml`) so the
   public profile can adopt them without anybody writing a second version.
   ========================================================================== */

/* The ring is a frame AROUND `.av`, never a replacement for it: the letter
   fallback, the storage-sized <img> and the broken-image handler all still
   come from `avatarHtml()`. */
/* ★ The fallback is a LITERAL, never `var(--a2)`. ★
   `--a2` is the brand/accent purple, and the accent follows the VIEWER's theme,
   so the same person's ring rendered purple here while Android rendered it in
   the viewer's own accent (pink) — that divergence was the reported bug. Both
   clients now fall back to the same gold (`PremiumFallbackHex` in
   ui/PremiumMark.kt). Change one, change both. */
.av-ring {
  --ring-fallback: #C9A227;
  --ring-gap: var(--s1);         /* the surface the avatar sits on */
  display: inline-block;
  padding: 3px;
  border-radius: var(--r-pill);
  background: var(--ring-c, var(--ring-fallback));
  line-height: 0;               /* no descender gap under an inline-block */
  flex: 0 0 auto;
}
/* The breathing gap between colour and photo is what makes it read as a ring
   rather than a coloured border — cut out of whatever surface is behind, the
   same trick `.av` already uses for the presence dot (line ~1355). */
.av-ring > .av,
.av-ring > img { border: 2px solid var(--ring-gap); border-radius: var(--r-pill); }

/* The badge beside a Korat ID. Tinted by the ring colour when there is one so
   the two purchases read as one identity, and by the shared fallback gold when
   the plan sells a badge but no ring (NOT the accent — see `.av-ring`). */
.idbadge {
  --ring-fallback: #C9A227;      /* same literal as `.av-ring`, never the accent */
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.15em; height: 1.15em; margin-inline-start: var(--sp-3);
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--ring-c, var(--ring-fallback)) 22%, transparent);
  color: var(--ring-c, var(--ring-fallback));
  font-size: .86em; line-height: 1;
  vertical-align: middle;
  flex: 0 0 auto;
}

/* ---- the preview block --------------------------------------------------
   Sits above the ring picker so a tap and its result are in one glance. */
.idpreview {
  --ring-gap: var(--s2);         /* this block's own surface, see below */
  background: var(--s2);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: var(--sp-5);
  margin-block: var(--sp-4);
  display: flex; flex-direction: column; gap: var(--sp-4);
}
/* "ตัวอย่าง" — it must be impossible to read this as "your profile now looks
   like this". Small, dim, and above the thing it labels. */
.idpreview-cap {
  font-size: var(--fs-micro); font-weight: 700; color: var(--dim2);
  letter-spacing: .04em; text-transform: none;
}
.idpreview-body { display: flex; align-items: center; gap: var(--sp-5); min-width: 0; }
.idpreview-txt { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.idpreview-name {
  display: flex; align-items: center; min-width: 0;
  font-size: var(--fs-title); font-weight: 800; color: var(--ink);
  overflow-wrap: anywhere;
}
.idpreview-sub {
  font-size: var(--fs-sm); color: var(--dim);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.idpreview-hint { margin: 0; }
/* Inside the sheet the 96px avatar is too much of a short screen; the preview
   is context for a decision, not a profile page. */
.idpreview .av.xl { width: 64px; height: 64px; font-size: 24px; }
@media (max-width: 359px) {
  .idpreview .av.xl { width: 52px; height: 52px; font-size: 20px; }
  .idpreview-name { font-size: var(--fs-row); }
}

/* On the entitlement card the preview is not a preview — it is what the person
   already owns — so it drops the border and the label and simply sits inside
   the card it belongs to. Same renderer, same ring, no second stylesheet. */
.idpreview.mine {
  --ring-gap: var(--s1);
  background: transparent; border: 0; padding: 0;
  margin-block: var(--sp-2) 0;
}

/* ==========================================================================
   LAYOUT UTILITIES — the one contract for "left column stretches,
   right column stays put", usable in ANY container.

   ★ WHY THESE EXIST ★
   `flex: 1; min-width: 0` lived only inside `.row .grow` and ~25 other
   per-container copies of the same declaration. Every flex row that was NOT
   one of those containers got a default flex item instead: `flex: 0 1 auto`
   with `min-width: auto`, i.e. a column that neither grows (so the price /
   button / status lands at whatever x the text happened to end at, different
   on every row) nor shrinks (so a long name shoves the right-hand column out
   of the card). The plan sheet was one instance of that; the survey found it
   in toolbars, card feet, plugin rows, the composer's attachment preview and
   the payment-method rows too.

   ★ WHY THEY ARE OPT-IN ★
   A bare `.grow { flex: 1 }` would reach ~326 existing elements across ~68
   modules that were all written against its absence — including ones where
   `grow` is a no-op spacer inside a `space-between` head, or a label inside a
   bubble that must stay shrink-to-fit. So the rule is a NEW class name, added
   deliberately at each site the survey verified. Nothing that does not ask
   for it changes.

     .u-grow   the column that takes the leftover space AND may shrink
     .u-fixed  its partner: the column that must not shrink and must not wrap
     .pricecol (below) the numeric case: .u-fixed + a shared minimum width
               (`--pricecol-w`) so figures line up down the page

   Specificity note: these are single classes (0,1,0), so any older
   `.container .grow` rule (0,2,0) still wins on the properties it names.
   They are applied only where no such rule exists, or where the older rule
   names different properties (`overflow`, `text-overflow`) and composes.
   ========================================================================== */
.u-grow  { flex: 1 1 auto; min-width: 0; }
.u-fixed { flex: 0 0 auto; white-space: nowrap; }

/* `.bizcard-foot` had a rule in `biz.css` and NONE here, so in the personal
   app the card's foot was a plain block: the amount and the action buttons
   stacked, and the `.grow` spacer between them did nothing at all. Same
   geometry as the console's, plus `flex-wrap` because this side is drawn on a
   360px phone where two buttons and a total do not always fit on one line. */
.bizcard-foot {
  display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap;
  margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line);
}

/* The "this name needs a different plan" box in the buy sheet. It carries a
   button, so it cannot be a bare `.hint` line — the whole point is that the
   reader can get to the plan that actually covers their name in one tap
   instead of paying for the one on screen. */
.plan-wrongplan {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-4);
  padding: var(--sp-4) var(--sp-5);
  border-radius: var(--r-md);
  border: 1px solid color-mix(in srgb, var(--amber) 34%, var(--line));
  background: color-mix(in srgb, var(--amber) 9%, transparent);
  margin-block: var(--sp-3);
}
/* `.hint.warn` paints the whole box amber; the sentence itself stays readable
   and only the box carries the tone. */
.plan-wrongplan > div { color: var(--ink); }
/* The checker inside the sheet is already on the sheet's surface, so it drops
   the standalone card chrome it wears on #/id. */
.plan-sheet .sheet-body > * .card:has(> div > .koratid-check) {
  background: transparent; border: 0; padding: 0; margin-block: 0;
}

/* ==========================================================================
   PRICE COLUMN, TAKE TWO — a GRID TRACK, not a flex `min-width`.

   ★ WHY THE LAST FIX DID NOT HOLD ★
   `.pricecol` was `flex: 0 0 auto; min-width: var(--pricecol-w)`. `min-width`
   is a FLOOR, not a width: the column is `max(content, 92px)`, so it is a
   different width on any row whose price is wider than the floor, and the
   left edge of the number therefore still moves from row to row. On top of
   that, a flex track's width is the OUTCOME of a negotiation with its
   sibling — anything in the left column that refuses to shrink (an
   unbreakable run, a `nowrap` chip, a stray inline style) changes the result
   for that row only. "Locked" was never actually locked; it was "usually
   lands in the same place".

   A grid TRACK is not negotiable. `grid-template-columns: minmax(0,1fr) Npx`
   gives the price column exactly N pixels on every row, whatever the content
   of either cell, and `minmax(0,…)` lets the left cell shrink without needing
   `min-width: 0` to be remembered on some inner element.

   Honest note for the record: I could not reproduce the reported row-3 offset
   from the CSS as written — every path I could compute right-aligned. So this
   is not "I found the cause"; it is "I removed the mechanism that allowed a
   per-row difference to exist at all". That is why it is a track and not
   another tweak.
   ========================================================================== */

:root {
  /* Widened from 92: `฿159,000/ปี` does not fit 92px, and under the old
     `min-width` that silently made THAT row's column wider than the others.
     A fixed track has to fit the widest real value in the catalogue. */
  --pricecol-w: 108px;
}

.plan-list-row,
.koratid-tier {
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--pricecol-w);
  align-items: center;
  column-gap: var(--sp-4);
}
/* The track already positions the cell; these must not also try to. An `auto`
   inline-start margin inside a grid item would push it within its own track. */
.plan-list-row > .pricecol,
.koratid-tier > .pricecol {
  margin-inline-start: 0;
  min-width: 0;          /* the track is the width now */
  width: 100%;
  justify-self: stretch;
}
/* Long money strings must not blow the track open. They cannot wrap (the
   number and its unit are one value), so on the rare overflow they scale down
   rather than push the layout — the alignment survives, which is the point. */
.plan-list-row > .pricecol,
.koratid-tier > .pricecol { overflow: hidden; text-overflow: ellipsis; }

@media (max-width: 359px) {
  :root { --pricecol-w: 94px; }
}

/* ==========================================================================
   NEON — the ONLY thing the ฿159,000 plan sells that you can see.

   ★ WHY THE FIRST ATTEMPT DID NOT MOVE ★
   It animated a custom property: `@property --neon-a` + `conic-gradient(from
   var(--neon-a))` + `@keyframes { to { --neon-a: 360deg } }`. That technique
   depends on registered-property interpolation, which is not everywhere, and
   where it is missing the declaration is simply ignored — leaving a conic
   gradient parked at 0deg. The failure mode is a ring that looks *exactly*
   like the plain one, which is the worst possible outcome for the single
   feature that justifies the price.

   The rewrite animates `transform: rotate()` on a pseudo-element instead.
   Transform animation is universal, it runs on the compositor, and a rotating
   circle's OUTLINE does not change — only the gradient inside it turns, which
   is precisely the "running around the ring" effect. Nothing here needs
   @property, so there is no browser where this silently degrades to static.
   ========================================================================== */
.av-ring.neon {
  position: relative;
  /* The colour now comes from the spinning layer, not from this box. */
  background: transparent;
  /* The glow. This is what separates neon from the plain ring even before
     anything moves, and it is deliberately NOT part of the animation. */
  box-shadow:
    0 0 7px color-mix(in srgb, var(--ring-c) 75%, transparent),
    0 0 20px color-mix(in srgb, var(--ring-c) 45%, transparent);
}
.av-ring.neon::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  /* A bright head that fades into the ring colour and back to transparent —
     that gradient is what reads as a light "running" once it turns. */
  background: conic-gradient(
    from 0deg,
    transparent 0deg,
    color-mix(in srgb, var(--ring-c) 55%, transparent) 60deg,
    var(--ring-c) 130deg,
    #fff 170deg,
    var(--ring-c) 210deg,
    color-mix(in srgb, var(--ring-c) 55%, transparent) 280deg,
    transparent 360deg);
  animation: neon-spin 2.2s linear infinite;
  /* The avatar must sit ON TOP of the spinning layer, not under it. */
  z-index: 0;
}
/* The avatar itself must sit ABOVE the spinning layer. `.av` is the div
   `avatarHtml()` emits everywhere except the profile head, which wraps a bare
   `<img class="profile-avatar-large">` (`avatarImg()` in profile.js) — without
   this second selector the ::before's `z-index: 0` stacking context still
   paints after this img's normal in-flow box, and the spinning gradient
   covers the photo entirely on exactly the screen a viewer looks at longest. */
.av-ring.neon > .av,
.av-ring.neon > img { position: relative; z-index: 1; }
@keyframes neon-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

/* Reduced motion: the ROTATION stops, the identity does not. The glow and the
   gradient both stay, so the ring is still visibly the expensive one — turning
   it back into a flat colour would take the feature away from exactly the
   readers who cannot opt back in. */
@media (prefers-reduced-motion: reduce) {
  .av-ring.neon::before { animation: none; }
}

/* ==========================================================================
   NEON ON CONTENT — the post card in the feed, and the comment bubble.

   ★ WHY THIS IS NOT THE AVATAR'S TECHNIQUE COPIED ★
   The ring works because a CIRCLE's outline does not change when you rotate it,
   so the spinning layer can BE the ring. A card is a rounded RECTANGLE: rotate
   the visible band and the band itself swings off the card. So the two jobs are
   split across two elements —
     `.neonfx`          holds still, is clipped to the card's radius, and has a
                        mask that cuts its own middle out, leaving a 2px frame;
     `.neonfx::before`  is the big conic gradient that TURNS inside it.
   The hole never moves, the light does. Still nothing but `transform`, still no
   layout, still no `@property`.

   ★ THE COST, AND WHY A LONG FEED STAYS SMOOTH ★
   Five glowing cards in view = five composited layers, each painting its
   gradient once and then only being re-transformed by the compositor: no
   layout, no style recalc, nothing touching the card's own text or photos.
   Three separate rations keep it there:
     · `animation-play-state: paused` by default. Only `.neon-live` runs, and
       neon.js grants that to at most FIVE on-screen cards at a time via one
       shared IntersectionObserver.
     · `.neon-halt` on <html> (tab hidden) pauses every one of them.
     · `contain: paint` on the layer, so its repaint can never escape the card.
   A card that is out of view, over the budget, or on a hidden tab still shows
   the glow and the gradient — it simply does not turn. It never degrades to a
   plain card, because "plain" is what people who did not pay get.
   ========================================================================== */
.post.neon,
.comment.neon > .bubble,
.idpreview-post.neon {
  position: relative;
  /* The static half of the identity — present before anything moves and kept
     in every reduced/paused state below. */
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--neon-c) 55%, transparent),
    0 0 10px color-mix(in srgb, var(--neon-c) 34%, transparent),
    0 0 26px color-mix(in srgb, var(--neon-c) 18%, transparent),
    var(--card-shadow);
  /* Still here for `.comment > .bubble` and `.idpreview-post`, which do have a
     border. A feed `.post` has none any more; the `0 0 0 1px` ring above is the
     same line at 55% instead of 45%, drawn on the outside of the box, so the
     card lost nothing when the border went. */
  border-color: color-mix(in srgb, var(--neon-c) 45%, var(--line));
}
/* THE CONTINUOUS FEED NEARLY ATE THE PAID GLOW. With the 20–24px gap gone, the
   next card's background is painted straight over the 26px halo — siblings with
   `z-index: auto` paint in DOM order, and every `.post` is now positioned. One
   step up the stack and the glow spills across its neighbours again, which is
   exactly what it did when the gap was doing that job. */
.post.neon { z-index: 1; }
/* The plan does not sell a COLOUR for content neon, so it follows the app's own
   accent unless the holder's ring colour is handed down (`--neon-c` is set on
   the card by whoever knows it). One variable, one place to change. */
.post.neon, .comment.neon > .bubble, .idpreview-post.neon { --neon-c: var(--a2); }

.post.neon > .neonfx,
.comment.neon > .bubble > .neonfx,
.idpreview-post.neon > .neonfx {
  position: absolute;
  inset: 0;
  /* ★ `inherit`, never a token. The frame is a masked layer sitting exactly on
     top of the card, so its corner MUST be the card's corner — the moment the
     two are written independently they drift, and the drift shows up as a lit
     arc floating off the corner of a square card (the owner caught precisely
     that in the demo). `.post` is now square, `.comment > .bubble` is still
     `--r-md`, and this one line is correct for both without knowing either. */
  border-radius: inherit;
  overflow: hidden;
  pointer-events: none;
  z-index: 3;
  contain: paint;
  /* The frame: 2px of gradient, the middle cut out. The mask does NOT rotate —
     only the layer underneath it does. */
  padding: 2px;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
}
.post.neon > .neonfx::before,
.comment.neon > .bubble > .neonfx::before,
.idpreview-post.neon > .neonfx::before {
  content: "";
  position: absolute;
  top: 50%; left: 50%;
  /* A SQUARE wider than the card's diagonal, centred — so the gradient covers
     every corner at every angle and no edge of the frame is ever left unlit. */
  width: 280%;
  aspect-ratio: 1;
  margin: -140% 0 0 -140%;
  background: conic-gradient(
    from 0deg,
    transparent 0deg,
    color-mix(in srgb, var(--neon-c) 50%, transparent) 55deg,
    var(--neon-c) 120deg,
    #fff 160deg,
    var(--neon-c) 200deg,
    color-mix(in srgb, var(--neon-c) 50%, transparent) 270deg,
    transparent 360deg);
  animation: neon-spin 3.4s linear infinite;
  animation-play-state: paused;      /* ← the default is OFF; see neon.js */
  transform: rotate(0deg);
}
/* Only what the observer says is on screen, and only within the budget. */
.post.neon.neon-live > .neonfx::before,
.comment.neon.neon-live > .bubble > .neonfx::before,
.idpreview-post.neon.neon-live > .neonfx::before {
  animation-play-state: running;
  will-change: transform;
}
/* Tab in the background: everything stops, including the ones in "view". */
.neon-halt .neonfx::before { animation-play-state: paused !important; will-change: auto; }

/* Reduced motion: same decision as the avatar ring — the rotation goes, the
   identity stays. Turning it back into an ordinary card would take the paid
   feature away from exactly the readers who cannot opt back in. */
@media (prefers-reduced-motion: reduce) {
  .post.neon > .neonfx::before,
  .comment.neon > .bubble > .neonfx::before,
  .idpreview-post.neon > .neonfx::before { animation: none; }
}

/* The bubble gets NO border of its own here on purpose: it never had one, and
   adding 1px only for the glowing ones would push that comment 2px wider than
   the plain comment above it. The frame is drawn entirely by `.neonfx`, which
   is absolutely positioned and costs the layout nothing. */

/* The quota line under the composer — the holder's own count, never anyone
   else's (the RPC has no way to ask about another person). */
.neon-quota { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
  font-size: var(--fs-micro); color: var(--dim); padding: 2px 0 0; }
.neon-quota .dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
  background: var(--a2); box-shadow: 0 0 6px color-mix(in srgb, var(--a2) 70%, transparent); }
.neon-quota.out .dot { background: var(--dim2); box-shadow: none; }
.neon-quota .when { color: var(--dim2); }

/* The buy sheet's miniature post card. Same classes as the real one, one size
   down — a demo that is prettier than the product is a lie with a price on it. */
.idpreview-post {
  position: relative; margin-top: var(--sp-4);
  /* Square, for the reason stated above it: this box exists to show what the
     buyer's own posts will look like. A rounded preview of a square card is a
     picture of a product that does not exist. Its `.neonfx` inherits from here,
     so the glowing frame follows the same square edge the real card has. */
  background: var(--s1); border: 1px solid var(--line); border-radius: 0;
  padding: var(--sp-4) var(--sp-5); overflow: hidden;
}
.idpreview-post .pv-head { display: flex; align-items: center; gap: var(--sp-4); }
.idpreview-post .pv-name { font-size: var(--fs-row); font-weight: 700; }
.idpreview-post .pv-when { font-size: var(--fs-micro); color: var(--dim2); }
.idpreview-post .pv-body { margin-top: var(--sp-4); font-size: var(--fs-body); color: var(--ink); }

/* The badge picks the glow up too, so one purchase reads as one look. */
.idpreview.neon .idbadge,
.av-ring.neon + * .idbadge {
  /* `--ring-c` is written by JS and is OPTIONAL — ui.js:264 sets it only when a
     colour is known, and `.idbadge` is reachable with none. A bare `var(--ring-c)`
     inside `color-mix()` is invalid at computed-value time, which does not fall
     back to anything: the WHOLE declaration is thrown away and the glow silently
     vanishes on exactly the badges that paid for it. `.idbadge` already declares
     `--ring-fallback` (line ~5427) and the two lines above already use this form;
     this one was the odd one out. */
  box-shadow: 0 0 8px color-mix(in srgb, var(--ring-c, var(--ring-fallback)) 60%, transparent);
}
.idpreview-av { display: inline-flex; flex: 0 0 auto; }

/* The refusal box in the buy sheet carries a retry button now, so it needs to
   be a box rather than a bare red line. */
.plan-quote.bad {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-4);
  border-color: color-mix(in srgb, var(--red) 34%, var(--line));
  background: color-mix(in srgb, var(--red) 8%, transparent);
}

/* ==========================================================================
   VERTICAL RHYTHM on #/id — "มันห่างกันเกินไป"

   ★ THE REAL CAUSE IS NOT "the numbers are too big" ★
   It is that ONE number was doing two different jobs. The column's gap
   separated *groups* (a heading and the block it owns are a group) and it
   also separated *a heading from its own content*. When those two distances
   are equal, a heading does not belong to anything — it floats between the
   block above and the block below, and the eye reads the page as N loose
   strips rather than a few sections. Adding air to a page with no grouping
   makes it longer, not clearer, which is exactly what the screenshot shows:
   three real objects per screen and the rest is nothing.

   So the fix is a two-step scale, not a smaller single step:

     within a group   8px   (--sp-4)  heading → its content, text → its card
     between groups   24px            supplied by the heading's OWN top padding
                                      (16px) on top of the 8px column gap

   A 3:1 ratio is what makes a heading read as attached to what follows it.
   The page also gets genuinely shorter: the old rhythm was 12px everywhere
   plus a 14px island inside the premium section (and 2px under every heading),
   i.e. ~12-26px between every pair of adjacent things.

   Scoped to `.view.koratid` and its two child stacks. `.section-label`,
   `.card` and `.hint` are shared with the whole app and are NOT touched
   globally — /app/me and the console keep their own spacing exactly as it is.
   Horizontal gutters (`padding-inline`) are untouched: this block sets no
   inline padding or margin at all.
   ========================================================================== */

.view.koratid { gap: var(--sp-4); }

/* Heading: plenty of room ABOVE it (that is the group separator), almost none
   below (it belongs to the thing under it). This replaces the earlier
   `> .section-label { padding-top: 0 }`, which had removed the separator
   entirely and left every gap looking the same. */
.view.koratid .section-label {
  padding-top: var(--sp-6);
  padding-bottom: 0;
}
/* The first heading on the screen has the hero above it and needs no group
   separator — the hero already is one. */
.view.koratid > .section-label:first-child { padding-top: 0; }

/* An explanatory paragraph belongs to the block it introduces, so it sits
   tight to it and takes its distance from what came before. */
.view.koratid .hint { line-height: 1.5; }

/* The premium section and the billing list are stacks inside the column: they
   inherit the same within-group step instead of inventing their own (14px and
   10px inline, which is what made this the airiest part of the page). */
.premium-sec,
.bill-stack {
  display: flex; flex-direction: column; gap: var(--sp-4); min-width: 0;
}
/* Inside those stacks the heading rule above already applies (it is scoped to
   `.view.koratid`, and both stacks live inside it). */

/* Cards in this column carry their own internal padding; they must not also
   add outer margin, or every card sits on a doubled gap. */
.view.koratid .card,
.view.koratid .hero-card { margin-block: 0; }

/* The name history and price tables are `.card > .list`; the list's own
   vertical padding is the card's inner air and stays. Only the outer stack
   is being tightened here. */

/* ==========================================================================
   THE SELL CARD — the one block on #/id that takes money.

   Heading + explanation + call to action were three separate blocks floating
   in the column, so the distance between them was the same "between groups"
   distance used everywhere else — which is most of what the owner was seeing
   as "ห่างเกินไป". Making them ONE card fixes the spacing and the emphasis in
   the same move: inside a card the air is the card's padding, not a gap.

   Colour: accent only (--a1/--a2/--a3, the app's own brand ramp).
     · NOT green  — that means "you hold this entitlement" on this very screen
     · NOT amber  — "waiting / warning" (the reserved-names note above it)
     · NOT red    — "failed"
   A sales card wearing a status colour would be read as a status.

   Loudness: a bordered, lightly tinted card. `.hero-card` (the @handle at the
   top) stays the loudest thing on the page because it is FILLED with the
   accent gradient and sets its value at --fs-hero; this one is OUTLINED. An
   outline reads as "important", a fill reads as "primary" — so the hierarchy
   is hero → sell card → ordinary cards, which is the order asked for.
   ========================================================================== */
.card.premium-card {
  border: 1.5px solid color-mix(in srgb, var(--a2) 55%, var(--line));
  border-radius: var(--r-lg);
  background:
    radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--a2) 10%, transparent), transparent 60%),
    var(--s1);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--a2) 9%, transparent);
  padding: var(--sp-5);
  gap: var(--sp-3);
  margin-block: 0;
}
/* A held plan is no longer an offer, so the card stops shouting: same shape,
   ordinary border. It must not turn green — green means "entitlement" on the
   status chips right above it and would read as a second status badge. */
.card.premium-card.owned {
  border-color: var(--line);
  border-width: 1px;
  background: var(--s1);
  box-shadow: var(--card-shadow);
}
/* The heading now lives INSIDE the card, so it is a card title, not a section
   label: no top padding (the card's own padding is the space) and it sits
   tight to the sentence it introduces. */
.premium-card-title {
  margin: 0;
  font-size: var(--fs-title);
  font-weight: 800;
  color: var(--ink);
  line-height: 1.35;
}
.premium-card-hint { margin: 0; }

/* The call to action. `.row`'s list density is too tight for the card's main
   action, and its `--s2` fill fights the card — transparent, roomier, and the
   whole row is the tap target. */
.premium-cta {
  display: flex; align-items: center; gap: var(--sp-4);
  width: 100%; margin-top: var(--sp-2);
  padding: var(--sp-4) 0 0;
  border: 0; border-top: 1px solid color-mix(in srgb, var(--a2) 22%, var(--line));
  border-radius: 0;
  background: transparent;
  text-align: start; cursor: pointer; font: inherit; color: inherit;
}
.premium-cta:hover { background: transparent; }
.premium-cta .l1 { display: block; font-size: var(--fs-row); font-weight: 700; color: var(--ink); }
.premium-cta .l2 { display: block; font-size: var(--fs-sm); color: var(--dim); margin-top: 1px; }
/* A real-looking filled button, but NOT `block` — the page's primary action
   ("ใช้ชื่อนี้") is the full-width one, and staying auto-width keeps this a
   step below it. It is a <span> on purpose: a <button> inside the <button>
   that wraps the whole row would be invalid and unreadable to a screen reader. */
.premium-cta > .btn { flex: 0 0 auto; pointer-events: none; padding: 7px var(--sp-5); }
.premium-cta:hover > .btn { background: var(--brand-hover); }
.card.premium-card.owned .premium-cta { border-top-color: var(--line); }
.card.premium-card.owned .premium-cta > .btn {
  background: transparent; border: 1px solid var(--line); color: var(--ink);
}

/* ---- the tightening, stated once ----------------------------------------
   With the sell block carded, the column has fewer, larger objects. The step
   between them can therefore come down without the page reading as cramped —
   the grouping is now carried by the cards, not by the gaps. */
.view.koratid { gap: var(--sp-3); }
.view.koratid .section-label { padding-top: var(--sp-5); padding-bottom: 0; }
.view.koratid > .section-label:first-child { padding-top: 0; }
/* A paragraph that introduces the block under it belongs to that block. */
.view.koratid > .hint { margin-block: 0; }
.premium-sec, .bill-stack { gap: var(--sp-3); }

/* "คุณทำได้เลย" — the name is claimable with the entitlement already held, so
   the box carries the action rather than an offer. Green here is correct and
   is NOT the sell card's accent: this is a status ("you hold this"), which is
   exactly what green means everywhere else in the app. */
.plan-cancomplete {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-4);
  padding: var(--sp-4) var(--sp-5);
  border-radius: var(--r-md);
  border: 1px solid color-mix(in srgb, var(--green) 34%, var(--line));
  background: color-mix(in srgb, var(--green) 9%, transparent);
  margin-block: var(--sp-3);
}
.plan-cancomplete > div { color: var(--ink); }
/* A disabled buy button must not look like it is merely loading — `.btn`'s
   `:disabled` opacity alone reads as "wait". This one is a settled answer. */
.plan-buyfoot .btn:disabled { opacity: 1; background: var(--s3); color: var(--dim); }

/* ==========================================================================
   v3 THEME — PASS 2: the rail, the right-rail card and the composer.

   Same discipline as pass 1: tokens only, nothing deleted, no shell or
   breakpoint touched, and every number that v3 states as a literal (radius 20,
   padding 4 / 12x14, 36px tool button) is written HERE, on the selector, and
   never pushed into `--r-lg` / `--r-md` — 5,800 lines of screens ride that
   ladder and v3 is a one-page demo.

   NOT taken from v3 in this pass, and why:
     · v3's stories strip has no counterpart in this app (there is no stories
       feature in web/app at all) — styling it would be CSS for a screen that
       does not exist.
     · v3's underlined top tab bar is a different control from this app's
       `.segs` / `.tabs`, which are segmented PILLS used both as screen tabs
       and as filter chips, on 23 call sites. Restyling them as underlines
       would silently turn every filter chip row into a tab bar; that needs a
       class in the markup to tell the two apart, which is JS.
   ========================================================================== */

@media (min-width: 640px) {
  /* The menu is a structured glass BOX with a divided row per item (v3:123-156).
     `--rail-pad` is re-declared on `.rail` itself rather than at `:root`, so
     `.rail .compose::before` — which pulls the same token to bleed its
     separator to the card edge — stays aligned instead of hanging 2-4px short. */
  .rail {
    --rail-pad: 4px;
    gap: 0;
    border-radius: 20px;
  }
  /* A hairline under every row, none under the last one. The `:has()` arm is
     for the social host, where a `.compose` button follows the links and
     already draws its own separator — without it that one gap carries two
     lines 8px apart. */
  .rail a {
    border: 1px solid transparent;
    border-bottom-color: var(--line);
    border-radius: 14px;
  }
  .rail a:last-child,
  .rail a:has(+ .compose) { border-bottom-color: transparent; }

  /* WHERE YOU ARE = the gradient, not the wash (v3:149-156).
     The earlier rule used `--sel` + `--ink` because white on a 15-20% accent
     wash is unreadable in the light theme — that lesson stands and is the
     reason this is a FILL: on the full brand gradient white is the only
     correct ink, in both themes, which is why it may come back here. */
  .rail a[aria-current="page"] {
    background: var(--brand);
    color: #FFFFFF;
    font-weight: 700;
    box-shadow: 0 4px 18px rgba(139, 61, 255, 0.38);
    border-color: transparent;
  }
  .rail a[aria-current="page"] svg { color: #FFFFFF; transform: scale(1.08); }
  /* The unread dot sat on `--s1` (a dark ring). On the gradient it needs the
     light ring v3 gives its badges, or it reads as a hole punched in the pill. */
  .rail a[aria-current="page"] .dot { border-color: rgba(255, 255, 255, 0.9); }
}

/* Right-rail widget (v3:1128-1137). Header stays plain text inside the card —
   this app never boxed it, and a box in a box is the thing v3 avoids. */
.railcard {
  padding: 12px 14px;
  border-radius: 20px;
}

/* Composer (v3:426-486). The tool button becomes a round accent button and the
   publish button a capsule; the textarea goes to 16px, which is v3's size and
   is also the size below which iOS Safari zooms the page on focus.

   MARKUP IS NOW IN PLACE (feed.js `composer()`), so the two halves that were
   parked here are done: the counter sits inside the button row (`.countslot`,
   v3 `.composer-submit`) and the publish button carries v3's 45°-rotated send
   glyph. The tool row is `.tools`, v3 `.composer-tools`, gap 4px.

   DELIBERATELY NOT v3: v3 draws FOUR tools (photo, GIF, poll, emoji). Two of
   them have no feature behind them in this app, and a dead button is worse than
   a missing one, so only photo and `#` are drawn. */
.composer textarea { font-size: 16px; }
/* v3 `.composer-tools { display:flex; gap:4px }`. Kept as one box so the row's
   own 8px gap does not spread the tools apart from each other. */
.composer .bar .tools { display: flex; align-items: center; gap: var(--sp-2); flex: 0 0 auto; }
/* THE SIZE IS STATED HERE, NOT INHERITED. Both tool buttons must be the SAME
   circle: a flex item with only `width` set can still be stretched by the row
   (`align-items`) or grown by a padding rule further up the cascade, and that is
   exactly how the `#` button turned into a tall pink lozenge next to a square
   photo icon. `flex: 0 0 auto` + fixed min/max on both axes + `padding: 0` +
   `aspect-ratio: 1` closes every one of those routes. */
.composer .bar .iconbtn {
  color: var(--a1); border-radius: 50%;
  flex: 0 0 auto; align-self: center; padding: 0;
  width: 36px; height: 36px;
  min-width: 36px; max-width: 36px; min-height: 36px; max-height: 36px;
  aspect-ratio: 1;
  background: color-mix(in srgb, var(--a1) 10%, transparent);
}
.composer .bar .iconbtn:hover {
  background: color-mix(in srgb, var(--a1) 15%, transparent);
  color: var(--a1);
  transform: scale(1.08);
}
.composer .bar .iconbtn svg { width: 20px; height: 20px; }
/* v3 is a desktop demo (36px). On touch both circles grow together — never one
   of them, or the pair stops matching. Every bound is restated, because a lone
   `width` here would lose to the `max-width` above. */
@media (pointer: coarse) {
  .composer .bar .iconbtn {
    width: 42px; height: 42px;
    min-width: 42px; max-width: 42px; min-height: 42px; max-height: 42px;
  }
}
.composer .bar .iconbtn svg { flex: 0 0 auto; }
/* THE ROW WRAPS. v3's row holds four tools, a counter and one button; this one
   also carries the series chip and the audience select, which v3 has nowhere at
   all. At 360px that is more than a line, and the honest answer is a second
   line — not a hidden counter (it has to stay true) and not a horizontal
   scroll. `.grow` is the hinge: it eats the slack on a wide screen and collapses
   to nothing on a narrow one, so the tools stay left and the rest lands right. */
.composer .bar { flex-wrap: wrap; row-gap: var(--sp-3); align-items: center; }
/* v3 `.composer-submit`. ONE flex item, so a wrap moves counter + series +
   audience + publish down as a block and the publish capsule is still the last
   thing on the last line, hard against the right edge. `margin-inline-start:
   auto` is the belt to `.grow`'s braces: even if `.grow` is ever removed or
   collapses, this cluster is still pushed right. */
.composer .bar .submit {
  display: flex; align-items: center; gap: var(--sp-3);
  flex: 0 1 auto; min-width: 0; margin-inline-start: auto;
  justify-content: flex-end; flex-wrap: nowrap;
}
/* The counter in the row loses `.counterbar`'s full-width stretch. `flex-end`
   inside `.charcount` still applies; this just stops it claiming a whole line. */
.composer .bar .countslot { display: inline-flex; align-items: center; flex: 0 0 auto; }
.composer .bar .countslot .charcount { gap: var(--sp-3); font-size: var(--fs-sm); }
/* v3 `.btn-publish`: capsule, label + glyph, the glyph rotated 45° so the paper
   plane points up-right out of the button. */
.composer .bar .btn {
  border-radius: var(--r-pill);
  display: inline-flex; align-items: center; gap: var(--sp-3);
  flex: 0 0 auto;
  padding: 7px var(--sp-5); font-size: var(--fs-base);
  white-space: nowrap;
}
.composer .bar .btn.send svg { width: 16px; height: 16px; transform: rotate(45deg); }
/* Mirrored writing systems: the plane must still fly away from the text, and
   `rotate(45deg)` alone would send it into the label. */
html[dir="rtl"] .composer .bar .btn.send svg { transform: scaleX(-1) rotate(45deg); }
/* v3 `.btn-publish:disabled { opacity:.4; box-shadow:none }` — the shadow has to
   go too, or an unusable button still glows like the page's primary action. */
.composer .bar .btn.send:disabled { box-shadow: none; }
/* 360px: the chip is the one control here whose text can be arbitrarily long
   (it prints the series title), so it is the one that gives. */
@media (max-width: 400px) {
  .composer .bar .chip[data-seriesbtn] {
    max-width: 42vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  /* 360px: the cluster itself may need two lines. It wraps to the RIGHT, so the
     publish capsule is still the last control on the last line and the counter
     never lands in the middle of the row. */
  .composer .bar .submit { flex-wrap: wrap; justify-content: flex-end; row-gap: var(--sp-2); }
}

/* --------------------------------------------------------------------------
   POST CARD — the remaining measured gaps against v3 (style.css:487-566).
   Appended, not edited in place, because `.post` is declared TWICE mid-file
   (l.967 and l.990) and the second one wins: a value changed in the first is
   silently discarded. Everything here was read off v3 line by line.

     v3                                    | before                | now
     .author-name       15px               | --fs-row  14px        | --fs-body
     .post-time         12px               | --fs-micro 11px       | --fs-sm
     .post-header       gap 12px           | --sp-4     8px        | --sp-5
     .post-actions-group gap 8px           | --sp-1     2px        | --sp-4
     .post-footer       no border, pt 8px  | border-top + 4px      | v3
     .like-btn.liked    pink on 15% pink   | white on brand fill   | v3
   -------------------------------------------------------------------------- */
.post .who .name { font-size: var(--fs-body); }
.post .when { font-size: var(--fs-sm); }
.post > .head { gap: var(--sp-5); }
/* v3's footer is `justify-content: space-between` with no rule above it — the
   hairline made this card's action row read as a separate strip bolted on, and
   that strip is exactly the "old" part of an otherwise frameless card. The card
   already ends in a divider (`::after`), so a second line 40px above it was
   drawing the same boundary twice. */
.post .acts {
  gap: var(--sp-4);
  border-top: 0;
  margin-top: var(--sp-4);
  padding-block: var(--sp-2) var(--sp-4);
}
/* Photos are full-bleed, so without this the first button touches the image.
   v3 gets the same 8px from `.post-body { margin-bottom: 14px }`. */
.post:has(.shots) .acts { margin-top: var(--sp-4); }
/* LIKED = TINT, NOT FILL (v3 `.like-btn.liked`). A solid gradient pill on every
   liked post makes the feed a field of buttons and outshouts the photo it sits
   under; v3 says the state with colour and a filled glyph instead. The glyph is
   already filled — `ICON.heartOn` is `fill="currentColor"` — so the colour is
   all that was missing. `--a1` follows the user's accent exactly as `--brand`
   did, so nothing here is newly hardcoded. */
.post .act.on {
  color: var(--a1);
  background: color-mix(in srgb, var(--a1) 15%, transparent);
  box-shadow: none;
}
.post .act.on:hover {
  color: var(--a1);
  background: color-mix(in srgb, var(--a1) 22%, transparent);
  transform: scale(1.06);
}
/* The skeleton must lose the same hairline in the same edit, or every load
   shows a bordered strip that vanishes the instant the real card lands. */
.sk-post .head { gap: var(--sp-5); }
.sk-post .foot { border-top: 0; padding-block: var(--sp-2) var(--sp-4); }

/* ==========================================================================
   PROFILE HEAD v3 — demo/social/v3 §.profile-header-wrap
   ปก full-bleed → อวาตาร์ทับมุมล่างซ้าย + ปุ่มแถวเดียวกันชิดขวา → ชื่อ/แนะนำตัว/
   ตัวเลข ชิดซ้ายทั้งหมด → แท็บขีดใต้. ทุกกฎสโคปใต้ `.profile-head.v3` / `.ptabs`
   ซึ่งมีที่เดียวในแอป (profile.js) — `.segs` และ `.cover` ของหน้าอื่นไม่ถูกแตะ.
   ========================================================================== */
.profile-head.v3 {
  align-items: stretch; text-align: start; gap: 0;
  padding: 0 0 var(--sp-6); overflow: hidden;
}
.profile-head.v3 > .cover {
  width: 100%; height: 148px; margin: 0 0 -48px;
  border: 0; border-radius: 0;
}
@media (max-width: 639px) { .profile-head.v3 > .cover { height: 116px; margin-bottom: -44px; } }

.profile-head.v3 .phead-bar {
  position: relative; z-index: 2;
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: var(--sp-4); padding: 0 var(--sp-6);
}
.profile-head.v3 .phead-av { flex: 0 0 auto; display: inline-flex; }
.profile-head.v3 .phead-acts { flex: 1 1 auto; display: flex; justify-content: flex-end; padding-bottom: var(--sp-3); }
.profile-head.v3 .av.xl { width: 88px; height: 88px; font-size: 30px; }
@media (max-width: 639px) { .profile-head.v3 .av.xl { width: 76px; height: 76px; font-size: 26px; } }
.profile-head.v3 .av { border: 4px solid var(--s1); box-shadow: var(--card-shadow); }

.profile-head.v3 .profile-acts {
  padding: 0; width: auto; justify-content: flex-end; gap: var(--sp-4);
}
.profile-head.v3 .profile-acts .btn { flex: 0 0 auto; border-radius: var(--r-pill); }
@media (max-width: 639px) { .profile-head.v3 .profile-acts .btn { flex: 0 0 auto; } }

.profile-head.v3 .ident {
  align-items: flex-start; text-align: start;
  gap: 2px; padding: var(--sp-5) var(--sp-6) 0;
}
.profile-head.v3 h2 { font-size: var(--fs-h); font-weight: 800; letter-spacing: -.01em; }
.profile-head.v3 .bio {
  max-width: 58ch; text-align: start; padding: var(--sp-4) var(--sp-6) 0;
}
.profile-head.v3 .pstats {
  display: flex; flex-wrap: wrap; gap: var(--sp-4) 20px;
  padding: var(--sp-4) var(--sp-6) 0;
  font-size: var(--fs-base); color: var(--dim);
}
.profile-head.v3 .pstats strong { color: var(--ink); font-weight: 700; }
.profile-head.v3 .pstats[hidden] { display: none; }

.ptabs {
  display: flex; gap: 0; margin: var(--sp-4) var(--sp-6) 0;
  border-bottom: 1px solid var(--line);
  overflow-x: auto; scrollbar-width: none;
}
.ptabs::-webkit-scrollbar { display: none; }
.ptabs button {
  position: relative; flex: 1 1 auto; min-width: fit-content;
  padding: var(--sp-5) var(--sp-4); border: 0; background: transparent; cursor: pointer;
  font-size: var(--fs-row); font-weight: 600; color: var(--dim); white-space: nowrap;
  transition: color .18s ease;
}
.ptabs button:hover { color: var(--ink); }
.ptabs button[aria-selected="true"] { color: var(--ink); font-weight: 700; }
.ptabs button[aria-selected="true"]::after {
  content: ""; position: absolute; inset-inline: var(--sp-4); bottom: -1px;
  height: 2px; border-radius: var(--r-pill); background: var(--brand);
}
/* ==========================================================================
   แชท — เทียบกับ demo/social/v3 (2026-08-14)
   วางท้ายไฟล์เท่านั้น: ทุกกฎในบล็อกนี้ "ทับ" ของเดิม ไม่มีการลบ selector ใด
   สามอย่างที่ v3 มีแล้วของจริงไม่มี:
     1) จอกว้าง = รายการแชท + ห้องสนทนา อยู่ข้างกัน (chat.js เปิดสองแพนที่ 1024px)
     2) แถวแชทเป็นแถวติดกันมีเส้นคั่น + แถวที่เลือกเป็นแถบไล่เฉด + ขีดซ้าย
        (ของเดิมเป็นการ์ดมนลอยเว้นห่างทุกแถว)
     3) ช่องค้นหาอยู่ในคอลัมน์รายการ ไม่ใช่พาดเต็มความกว้างหน้า
   ========================================================================== */

/* ---------- 1. คอลัมน์รายการ: หัว + ช่องค้นหา ---------- */
.chatpane-head {
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
  padding: 12px 14px;
  background: var(--s1);
  border-block-end: 1px solid var(--line);
}
.chatpane-head h2 { font-size: var(--fs-title); font-weight: 800; margin: 0; }
.chatpane-search {
  padding: 10px 14px;
  background: var(--s1);
  border-block-end: 1px solid var(--line);
}
.chatpane-search .field { width: 100%; }

/* ---------- 2. แถวแชท: ติดกัน มีเส้นคั่น ---------- */
/* `.list` ให้ gap + padding และ `.row` ให้พื้นการ์ดมนกับทุกแถว — นั่นคือ
   "การ์ดมนลอยเว้นห่าง" ที่เจ้าของเห็น. ที่นี่ยกเลิกเฉพาะรายการแชท
   (`.list.chatlist` เท่านั้น) รายการอื่นทั้งแอปไม่ขยับ. */
.list.chatlist {
  gap: 0; padding: 0;
  background: var(--s1);
  border-block: 1px solid var(--line);
}
.list.chatlist > .row {
  background: transparent;
  border: 0;
  border-radius: 0;
  border-block-end: 1px solid var(--line);
  padding-block: 10px;
  /* 11px ไม่ใช่ 14px: ขีดของแถวที่เลือกหนา 3px วาดทับด้านใน ทุกแถวจึงเรียงตรงกัน */
  padding-inline: 11px 14px;
  box-shadow: inset 3px 0 0 transparent;
}
html[dir="rtl"] .list.chatlist > .row { box-shadow: inset -3px 0 0 transparent; }
.list.chatlist > .row:last-child { border-block-end: 0; }
.list.chatlist > .row:hover { background: var(--hover); }
.list.chatlist .av-wrap .online { border-color: var(--s1); }
.list.chatlist .l2 { font-size: var(--fs-sm); color: var(--dim); }

/* แถวที่เลือกอยู่ — แถบไล่เฉดจาง ๆ พร้อมขีดซ้าย (v3) */
.list.chatlist > .row.inbox-row-active,
.list.chatlist > .row[aria-current="true"] {
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--a1) 12%, transparent),
    color-mix(in srgb, var(--a2) 16%, transparent));
  box-shadow: inset 3px 0 0 var(--a1);
}
html[dir="rtl"] .list.chatlist > .row.inbox-row-active,
html[dir="rtl"] .list.chatlist > .row[aria-current="true"] { box-shadow: inset -3px 0 0 var(--a1); }
.list.chatlist > .row.inbox-row-active .l1 { font-weight: 700; }

/* ยังไม่ได้อ่าน — ชื่อหนาขึ้น + ตัวเลขจริงจากคลัง (ไม่มีเลขปลอมที่ไหน) */
.list.chatlist > .row.inbox-unread .l1 { font-weight: 800; }
.list.chatlist > .row.inbox-unread .l2 { color: var(--ink); }
.list.chatlist .meta .chip.red {
  min-width: 18px; height: 18px; padding: 0 5px;
  border-radius: var(--r-pill);
  background: var(--brand); color: #fff;
  box-shadow: var(--brand-glow);
  font-size: var(--fs-micro); font-weight: 800; line-height: 1;
  display: inline-grid; place-items: center;
}

/* ---------- 3. สองแพน: รายการ + ห้อง ----------
   `.inbox-split / .inbox-list-pane / .inbox-thread-pane` มีสไตล์อยู่ใน biz.css
   เท่านั้น ซึ่ง apex ไม่โหลด — แปลว่ากล่องข้อความร้านใน `#/work/<id>/inbox`
   บน apex ทุกวันนี้ไม่มีสไตล์เลย. กฎชุดนี้จึงเขียนแบบไม่ผูกกับหน้าแชท
   เพื่อให้ทั้งสองที่ได้พร้อมกัน (ค่าเดียวกับ biz.css แปลงเป็นโทเคน apex). */
.inbox-split { display: flex; flex-direction: column; }
.inbox-thread-pane { display: none; }
.inbox-split[data-mode="thread"] .inbox-list-pane { display: none; }
.inbox-split[data-mode="thread"] .inbox-thread-pane { display: block; }
.inbox-thread-placeholder {
  display: none; align-items: center; justify-content: center; text-align: center;
  padding: 48px 20px; color: var(--dim); font-size: var(--fs-row);
}
.inbox-unread { background: color-mix(in srgb, var(--a2) 6%, transparent); }
.inbox-unread .l1 { font-weight: 800; }
.inbox-row-active { background: var(--hover); }

@media (min-width: 900px) {
  .inbox-split {
    display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
    border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden;
    background: var(--s1);
  }
  .inbox-list-pane {
    display: block !important;
    border-inline-end: 1px solid var(--line);
    overflow-y: auto; max-height: 640px;
  }
  .inbox-thread-pane { display: flex !important; flex-direction: column; min-width: 0; }
  .inbox-thread-placeholder { display: flex; }
  .inbox-thread-pane .chatroom { flex: 1; min-height: 0; height: auto; }
  .inbox-thread-pane .msgs { flex: 1; }
  /* ปุ่มย้อนกลับมีไว้ตอนจอแคบซึ่งเห็นทีละแพน — จอกว้างรายการอยู่ข้าง ๆ แล้ว */
  .inbox-thread-pane [data-back] { display: none; }
}

/* หน้าแชทจริง (chat.js) เปิดสองแพนที่ 1024px = จุดเดียวกับที่ `.page`
   กลายเป็นสามคอลัมน์ ⇒ เรลซ้าย · รายการ+ห้องตรงกลาง · วิดเจ็ตขวา = 3 คอลัมน์แบบ v3.
   ต่ำกว่า 1024px `body.chatsplit` ไม่เคยถูกใส่ และ `.chatsplit-view` ไม่ถูกสร้าง
   ⇒ มือถือได้หน้าเดิมทุกพิกเซล. */
.chatsplit-view { padding: 0; }

@media (min-width: 1024px) {
  /* คอลัมน์กลางกินที่ว่างที่เหลือเฉพาะหน้าแชท — 640px แคบไปสำหรับสองแพน */
  body.chatsplit .page {
    grid-template-columns: var(--rail-w) minmax(0, 1fr) var(--aside-w);
    max-width: 1720px;
  }
  .chatsplit-view .inbox-split {
    height: calc(100dvh - var(--appbar-h) - var(--colhead-h));
    border: 0; border-radius: 0; background: var(--bg);
    grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
  }
  .chatsplit-view .inbox-list-pane {
    display: flex !important; flex-direction: column;
    max-height: none; height: 100%; overflow: hidden;
    background: var(--s1);
  }
  .chatsplit-view .inbox-list-pane .list.chatlist {
    flex: 1; min-height: 0; overflow-y: auto; border-block: 0;
  }
  .chatsplit-view .inbox-thread-pane .chatroom { height: 100%; }
  .chatsplit-view .inbox-thread-pane { border-inline-start: 1px solid var(--line); }
}

/* ---------- 4. หัวห้องสนทนา: อวาตาร์ + สถานะ ---------- */
.row.chatroom-head {
  flex: 0 0 auto;
  background: var(--s1);
  border-radius: 0;
  border-block-end: 1px solid var(--line);
  padding: 10px 14px;
}
.row.chatroom-head .av-wrap .online { border-color: var(--s1); }
/* จุดเขียว/บรรทัด "ออนไลน์" วาดต่อเมื่อ `isOnline()` เป็นจริงเท่านั้น
   (last_seen เต้นภายใน 90 วินาที) — ไม่มี heartbeat = ไม่มีจุด */
.row.chatroom-head .l2.is-online { color: var(--green); font-weight: 500; }

/* ---------- 5. ฟองข้อความ + ช่องพิมพ์ (v3) ---------- */
/* ฟองของเรากลับมาเป็นไล่เฉดตามภาพ v3 (กฎเก่าในไฟล์นี้แบนมันเป็น `var(--accent)`
   สีเดียว — ภาพที่เจ้าของเทียบคือไล่เฉด จึงทับกลับที่นี่ พร้อมเงาแบรนด์). */
.msg.mine .bubble {
  background: var(--brand); color: #fff;
  box-shadow: var(--brand-glow);
  border-radius: var(--r-lg); border-end-start-radius: 16px; border-end-end-radius: 5px;
}
/* ของเขา: พื้นเทา มุมมน มีเส้นขอบบาง ๆ ให้แยกออกจากพื้นในโหมดสว่าง
   (โหมดสว่าง --s2 = #FFFFFF เท่ากับพื้นการ์ด ถ้าไม่มีเส้นจะกลืนหายไปเลย) */
.msg:not(.mine) .bubble { border: 1px solid var(--line); }
/* เวลาอยู่นอกฟองอยู่แล้ว (`.stamp`) — แค่ให้ระยะเท่ากับ v3 */
.msg .stamp { margin-top: 3px; }

/* ช่องพิมพ์เป็นแคปซูล + ปุ่มส่งกลมไล่เฉด (ไอคอนเครื่องบินคือ `ICON.send` เดิม) */
.composer-bar textarea { border-radius: var(--r-pill); }
.composer-bar .btn { box-shadow: var(--brand-glow); }

/* ==========================================================================
   หัวร้าน/เพจ v3 — `.bizhead` (book.js `shopHero` · shop.js `renderShop`)
   วางท้ายไฟล์: ทุกกฎที่นี่ "ทับ" ของเดิม ไม่มี selector ใดถูกลบ
   (`.shophero-*` และ `.shophead` ยังอยู่ครบที่บรรทัด 2417 และ 2451 เป็นต้นไป
    เพียงแต่ไม่มีใครใช้แล้ว).

   ★ ทำไมไม่มีคลาสรูปทรงชุดใหม่ ★
   หัวร้านใช้ `.profile-head.v3` + ลูกของมันชุดเดียวกับหน้าโปรไฟล์คนเป๊ะ ๆ
   (ปกเต็มขอบ → โลโก้ทับมุมล่างซ้าย + ปุ่มแถวเดียวกันชิดขวา → ชื่อ/รายละเอียด
   ชิดซ้าย → แท็บขีดใต้ `.ptabs`). `.bizhead` เติมเฉพาะสิ่งที่ "ร้าน" มีแต่
   "คน" ไม่มี — ชิปเปิด/ปิด, ดาว, อีโมจิแทนปกที่ยังไม่มีรูป, ป้ายเตือนร้านปิด —
   และไม่มีกฎใดในบล็อกนี้ที่เปลี่ยนรูปทรงของ v3. แก้ที่เดียว ขยับพร้อมกันทั้งสองหน้า.
   ========================================================================== */

/* ปกที่ยังไม่มีรูป: อีโมจิของประเภทร้านกลางแผง ไม่ใช่ช่องว่างที่อ่านเหมือนรูปพัง.
   (พื้นไล่เฉดของ `.profile-head .cover` ยังทำงานอยู่ — ที่เติมคือการจัดกลางเท่านั้น) */
.bizhead > .cover { display: grid; place-items: center; }
.bizhead > .cover.noimg { font-size: 40px; line-height: 1; }
@media (min-width: 640px) { .bizhead > .cover.noimg { font-size: 52px; } }

/* โลโก้ร้านเป็นสี่เหลี่ยมมนไม่ใช่วงกลม — ร้านมีโลโก้เป็นตราสินค้า ไม่ใช่รูปหน้าคน
   และการครอปโลโก้เป็นวงกลมกินตัวอักษรที่มุมทิ้ง. ขนาด/ขอบ/เงา ยังเป็นของ v3. */
.bizhead .av.xl { border-radius: var(--r-lg); }

/* บรรทัดใต้ชื่อ = ประเภทร้าน · อาหาร · ระดับราคา (หรือเวลาทำการ · ที่อยู่ สำหรับ
   ร้านค้าปลีก) — ยาวได้สองบรรทัด ที่อยู่ไม่ควรถูกตัดหายไปครึ่งประโยค */
.bizhead .handle {
  color: var(--dim); overflow-wrap: anywhere;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
}

/* แถวสถานะ: ชิปเปิด/ปิด · เวลา · ดาว — ใช้ช่อง `.pstats` ของ v3 (ชิดซ้าย เว้น 20px)
   แต่จัดกึ่งกลางแนวตั้งเพราะชิปสูงกว่าตัวหนังสือ */
.bizhead .pstats { align-items: center; }
.bizhead .pstats .sub { font-size: var(--fs-sm); color: var(--dim); }
.bizhead .pstats .rate { display: inline-flex; align-items: baseline; gap: var(--sp-2); }
.bizhead .pstats .rate b { font-size: var(--fs-row); font-weight: 700; color: var(--ink); }
.bizhead .pstats .stars { color: var(--amber); letter-spacing: .5px; font-size: var(--fs-sm); }
.bizhead .pstats .rate small { font-size: var(--fs-micro); color: var(--dim2); }

/* ปุ่มบนหัวร้านมีได้ถึงสามปุ่ม (โทร · เส้นทาง · บันทึก / บันทึก · รายงาน) ต่างจาก
   โปรไฟล์คนที่มีปุ่มเดียว — จึงยอมให้ตกบรรทัดชิดขวาแทนที่จะบีบจนอ่านไม่ออกที่ 360px */
.bizhead .profile-acts { flex-wrap: wrap; row-gap: var(--sp-3); }
.bizhead .phead-acts { min-width: 0; }
/* `reportLink` เป็น `.linkbtn` ไม่ใช่ `.btn` — จัดให้อยู่ระดับเดียวกับปุ่มข้าง ๆ */
.bizhead .profile-acts .linkbtn { align-self: center; font-size: var(--fs-sm); }

/* ป้ายเตือน (ร้านปิด / อ่านเวลาทำการไม่ได้) อยู่ในการ์ดหัวร้าน จึงต้องมีระยะขอบ
   ของตัวเอง — `.profile-head.v3` ตั้ง padding แนวนอนเป็น 0 ให้ปกเต็มขอบ */
.bizhead > .banner { margin: var(--sp-4) var(--sp-6) 0; }

/* แถบแท็บใต้หัวร้านใช้ `.ptabs` ตัวเดียวกับหน้าโปรไฟล์คน — ไม่มีคลาสแท็บชุดที่สอง
   ในแอปนี้. หัวร้านเป็นการ์ดที่มี margin อยู่แล้ว จึงเว้นบนอีกนิดให้ขีดใต้ไม่ติดการ์ด. */
.bizhead + .ptabs { margin-top: var(--sp-5); }
/* หน้าร้านอาหาร/ที่พัก/บริการ วางป้ายเตือนกับการ์ด "สั่งที่โต๊ะ" คั่นระหว่างหัวร้าน
   กับแถบแท็บ ⇒ `.bizhead + .ptabs` ไม่ติด. ให้ระยะเดียวกันผ่านตัวหน้าเพจแทน. */
.shoppage > .ptabs { margin-top: var(--sp-5); }
/* ==========================================================================
   แจ้งเตือน · ค้นหา · ที่บันทึกไว้ · เทรนด์ — ภาษา v3 (2026-08-14)
   วางท้ายไฟล์เท่านั้น ไม่มีการลบ selector เดิม ทุกกฎสโคปใต้คลาสใหม่
   (`.view.v3-notifs` · `.chiprow.v3-catbar` · `.trend-rank`) จึงไม่มีหน้าอื่น
   ขยับตาม. ใช้คลาสเดิมทั้งหมด (`.ptabs` จากหน้าโปรไฟล์ · `.chiprow`/`.chip.pick`
   · `.row`) — ไม่มีชิป/แท็บเวอร์ชันที่สอง.
   ========================================================================== */

/* ---------- 1. แถบหมวดแนวนอน (v3 `.category-chips-bar`) ---------- */
/* ของเดิม `.chiprow` ตัดบรรทัดลงมาเรื่อย ๆ — บนมือถือหน้าค้นหามี 6 ชิป
   กลายเป็นสามแถวก่อนถึงผลลัพธ์แถวแรก. v3 ให้เลื่อนแนวนอนแถวเดียวแทน. */
.chiprow.v3-catbar {
  flex-wrap: nowrap;
  overflow-x: auto;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  gap: var(--sp-3);
  padding: 10px var(--sp-6);
  border-block-end: 1px solid var(--line);
  scroll-padding-inline: var(--sp-6);
}
.chiprow.v3-catbar::-webkit-scrollbar { display: none; }
.chiprow.v3-catbar > .chip { flex: 0 0 auto; }
.chiprow.v3-catbar[hidden] { display: none; }   /* saved.js ซ่อนแถบเมื่อยังไม่มีของ */

/* ---------- 2. อันดับเทรนด์ (v3 `.trend-rank`) ---------- */
/* แทนวงกลม "#" ที่ไม่ได้บอกอะไร — กว้างเท่าอวาตาร์ inline เดิม แถวจึงไม่ขยับ */
.trend-rank {
  display: inline-grid; place-items: center;
  width: 30px; height: 30px;
  font-size: var(--fs-row); font-weight: 900;
  font-variant-numeric: tabular-nums;
  color: var(--a1);
}

/* ---------- 3. หน้าแจ้งเตือน: แถวติดกันเต็มความกว้าง (v3 `.notif-item`) ---------- */
/* เหตุผลเดียวกับ `.list.chatlist` ด้านบน: `.list` ให้ gap + padding และ `.row`
   ให้พื้นการ์ดมนทุกแถว ⇒ การ์ดลอยเว้นห่าง. v3 เป็นแถวติดกันมีเส้นคั่น.
   สโคปใต้ `.view.v3-notifs` (notifications.js หน้าเต็มเท่านั้น) — แผงกระดิ่ง
   ไม่มีคลาสนี้ จึงไม่ขยับ. */
.view.v3-notifs .ptabs { margin-block-start: 0; }
.view.v3-notifs > .list {
  gap: 0; padding: 0;
  background: var(--s1);
  border-block-end: 1px solid var(--line);
}
.view.v3-notifs > .list > .row.notif {
  background: transparent;
  border: 0;
  border-radius: 0;
  border-block-end: 1px solid var(--line);
  padding-block: 12px;
  padding-inline: 11px 14px;
  box-shadow: inset 3px 0 0 transparent;
}
html[dir="rtl"] .view.v3-notifs > .list > .row.notif { box-shadow: inset -3px 0 0 transparent; }
.view.v3-notifs > .list > .row.notif:last-child { border-block-end: 0; }
.view.v3-notifs > .list > .row.notif:hover { background: var(--hover); }

/* ยังไม่อ่าน = แถบไล่เฉดจาง + ขีดซ้าย (v3 `.notif-item.unread`).
   ยังไม่ได้แทนที่จุดกลม `.udot` กับตัวหนาที่มีอยู่ — สามสัญญาณตามกฎเดิม
   (สี · น้ำหนักตัวอักษร · จุด) ไม่ได้ลดเหลือสีอย่างเดียว. */
.view.v3-notifs > .list > .row.notif.unread {
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--a1) 10%, transparent),
    color-mix(in srgb, var(--a2) 14%, transparent));
  box-shadow: inset 3px 0 0 var(--a1);
}
html[dir="rtl"] .view.v3-notifs > .list > .row.notif.unread { box-shadow: inset -3px 0 0 var(--a1); }

/* หัวข้อ "ใหม่ (3)" / "ก่อนหน้านี้" อยู่ในรายการเดียวกัน — ให้เป็นแถบคั่นเต็มกว้าง */
.view.v3-notifs > .list > .notif-sec {
  padding: 10px var(--sp-6) 6px;
  background: var(--s2);
  border-block-end: 1px solid var(--line);
}

/* ==========================================================================
   โพสต์เดี่ยว + ความคิดเห็น — ภาษา v3 (2026-08-14)
   เทียบกับ `demo/social/v3/style.css` §`.comment-item` / `.comment-bubble` /
   `.comment-meta` / `.comment-input-row`. วางท้ายไฟล์: ทุกกฎ "ทับ" ของเดิม
   ไม่มี selector ใดถูกลบ — `.comment` · `.comment.reply` · `.comment-more` ·
   `.commentbox-sticky` · `.replyto` ที่บรรทัด 1477–1582 ยังอยู่ครบและยังทำงาน.

   ★ สองข้อที่ห้ามทำหาย ★
   1) **ฟองกว้างเต็มเสมอ** ไม่ว่าข้อความจะสั้นแค่ไหน — มาจาก `flex: 1` ที่
      บรรทัด 1478 ซึ่งบล็อกนี้ไม่แตะเลย. ห้ามเปลี่ยนเป็น `width: fit-content`
      หรือ `flex: 0 1 auto` เด็ดขาด: ฟองที่หดตามข้อความทำให้เธรดเป็นขั้นบันได
      และเป็นสิ่งที่เจ้าของสั่งแก้ไปแล้วครั้งหนึ่ง.
   2) **นีออนต้องวิ่ง** — กรอบหมุนวาดโดย `.comment.neon > .bubble > .neonfx`
      (บรรทัด 5766–5858) ซึ่งเป็นเลเยอร์ absolute ทับฟอง. เส้นขอบที่เติมด้านล่าง
      ให้ "ทุกฟอง" เท่ากันหมด จึงไม่ทำให้ฟองที่เรืองแสงกว้างกว่าฟองธรรมดา
      (นั่นคือเหตุผลเดียวที่คอมเมนต์บรรทัด 5860 บอกว่าไม่ให้ใส่ขอบ — เพราะตอนนั้น
      จะใส่ให้เฉพาะอันที่เรืองแสง). `border-color` ของนีออนที่บรรทัด 5780 กลับมา
      มีผลจริงพอดี ⇒ ฟองนีออนได้ขอบเรืองอ่อน ๆ เพิ่มโดยไม่ขยับ layout สักพิกเซล.
   ========================================================================== */

/* ฟองความคิดเห็น: v3 มีเส้นขอบ 1px รอบฟอง — ในโหมดสว่าง `--s2`/`--s3` ใกล้พื้น
   การ์ดมาก ถ้าไม่มีเส้นฟองตอบกลับจะกลืนหายไปกับพื้นเลย (มือถือ+สว่างมาก่อน). */
.comment > .bubble {
  border: 1px solid var(--line);
  padding: 10px 14px;
}
.comment .name { font-weight: 700; }

/* แถวการกระทำใต้ข้อความ (v3 `.comment-meta`: เว้นบน 6px, ห่างกัน 12px).
   ของเดิมเว้นบนแค่ 2px ทำให้ "ถูกใจ · ตอบกลับ" ติดบรรทัดข้อความจนอ่านรวมกัน. */
.comment .when { margin-top: 6px; }

/* ไอคอน ถูกใจ · ตอบกลับ · ⋮ ในคอมเมนต์เดียวกันคือเครื่องมือของแถวเดียว — เจ้าของ
   ชี้จากภาพจริงว่า gap 12px (ของ v3 ต้นฉบับ, บรรทัดบนสุดของบล็อกนี้) ดันให้มันดู
   เหมือนกระจายเป็นคนละพวก. ชิดลงเหลือ 6px (ไม่ต่ำกว่านี้ — ยังต้องแตะง่ายบนมือถือ)
   และห้ามห่อบรรทัด: เวลา "4 วันที่ผ่านมา" บนคอมเมนต์ตอบกลับ (บับเบิลแคบ, เยื้อง
   40px) เคยตกบรรทัดเพราะ flex ปล่อยให้ span ของมันหดจนตัวช่วยตัดคำภาษาไทยของ
   เบราว์เซอร์ตัดกลางคำ — `white-space: nowrap` ล็อกไว้ ไม่ใช่การย่อข้อความเอง
   (`timeAgo()` เป็นคนสั้นให้อยู่แล้ว โปรดดูฟังก์ชันนั้นถ้าต้องย่อเพิ่ม). */
.comment-meta {
  gap: var(--sp-3);
  flex-wrap: nowrap;
  align-items: center;
}
.comment-meta > span:first-child { flex: 0 0 auto; white-space: nowrap; }
.comment-meta .reply-link { flex: 0 0 auto; }

/* หัวใจในปุ่มถูกใจ: `ICON.heartOn` เป็น svg 20px ตามขนาดปุ่มในฟีด — ในแถวขนาด
   `--fs-micro` ต้องย่อ ไม่งั้นดันความสูงของทั้งแถว. สถานะ "ถูกใจแล้ว" จึงมีสอง
   สัญญาณตามกฎเดิม (สีจาก `.linkbtn.on` + หัวใจทึบ) ไม่ใช่สีอย่างเดียว. */
.comment .when .linkbtn {
  display: inline-flex; align-items: center; gap: 4px;
}
.comment .when .linkbtn svg { width: 13px; height: 13px; flex: 0 0 auto; }

/* เส้นสายของเธรด: การตอบกลับเยื้องเข้า 34px อยู่แล้ว (บรรทัด 1491) — เติมเส้นตั้ง
   ให้เห็นว่ามันห้อยอยู่ใต้ความคิดเห็นไหน แทนที่จะเป็นแค่ย่อหน้าที่เยื้องเฉย ๆ.

   ★ ระยะเยื้องเดิมไม่ขยับสักพิกเซล ★ — เส้นวาดด้วย `::before` แบบ absolute
   จึงไม่กินที่ในกล่อง. `-12px` วัดจากขอบของ `.comment.reply` (x=34) ⇒ เส้นตกที่
   x=22 พอดี = กึ่งกลางอวาตาร์ของความคิดเห็นแม่ (padding 8 + ครึ่งของ `.av.xs`
   28px). `inset-inline-start` เป็นตำแหน่ง logical ⇒ RTL สลับข้างให้เอง. */
.comment.reply { position: relative; }
.comment.reply::before {
  content: ""; position: absolute; inset-block: -3px; inset-inline-start: -12px;
  width: 2px; border-radius: var(--r-pill); background: var(--line);
}
/* แถว "ดูการตอบกลับอีก n รายการ" อยู่ในระดับเดียวกับการตอบกลับ (บรรทัด 1516)
   จึงต้องมีเส้นเดียวกันต่อลงมา ไม่งั้นเส้นขาดกลางเธรด */
.comment-more.reply { position: relative; }
.comment-more.reply::before {
  content: ""; position: absolute; inset-block: -3px auto; height: 50%;
  inset-inline-start: -12px;
  width: 2px; border-radius: var(--r-pill); background: var(--line);
}

/* ช่องเขียน (v3 `.comment-field-wrap`): แคปซูลเส้นเดียวหุ้มทั้งช่องพิมพ์และปุ่มส่ง
   `.composer-bar textarea` ได้มุมแคปซูลจากบล็อกแชทแล้ว ที่นี่เติมเฉพาะกรอบนอก. */
.commentbox .composer-bar {
  gap: var(--sp-3);
  align-items: flex-end;
}
/* ป้าย "กำลังตอบกลับ …" — v3 ให้เป็นแถบไล่เฉดจางพร้อมขีดหน้า เพื่อไม่ให้ผู้เขียน
   ส่งข้อความไปผิดเธรดโดยไม่ทันสังเกต (ของเดิมเป็นพื้นเทาแบนกลืนกับช่องพิมพ์). */
.replyto {
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--a1) 12%, transparent),
    color-mix(in srgb, var(--a2) 16%, transparent));
  box-shadow: inset 3px 0 0 var(--a1);
  color: var(--ink);
  padding-inline-start: var(--sp-5);
}
html[dir="rtl"] .replyto { box-shadow: inset -3px 0 0 var(--a1); }

/* หัวข้อ "ความคิดเห็น (12)" บนหน้าโพสต์เดี่ยว: ตัวเลขคือจำนวนจริงจาก
   `loadSupporting` ไม่ใช่จำนวนที่วาดอยู่ — ดูเหตุผลใน feed.js `renderPost`. */
.comments-head {
  display: flex; align-items: baseline; gap: var(--sp-3);
  flex-wrap: nowrap;
}
/* "ความคิดเห็น (6)" ตกเป็นสองบรรทัดมาแล้วจริง — ไม่ใช่ล้นความกว้าง (ข้อความสั้น
   กว่าการ์ดมาก) แต่ตัวช่วยตัดคำภาษาไทยของเบราว์เซอร์ตัดกลางคำ "ความคิดเห็น" เมื่อ
   flex ปล่อยให้ span แรกหดได้ (flex-shrink ค่าเริ่มต้น = 1). ล็อกไม่ให้หด/ห่อ. */
.comments-head > span:first-child { flex: 0 0 auto; white-space: nowrap; }
.comments-head .n {
  font-size: var(--fs-micro); font-weight: 700; color: var(--dim2);
  font-variant-numeric: tabular-nums;
  white-space: nowrap; flex: 0 0 auto;
}

/* ==========================================================================
   เพื่อน (`friends.js`) — ภาษา v3 (2026-08-14)  ★ หน้าตาล้วน ★
   วางท้ายไฟล์: ทุกกฎที่นี่ "ทับ" ของเดิม ไม่มี selector ใดถูกลบ.
   ไม่มีกฎใดในบล็อกนี้แตะตรรกะการเปิดแชท — ปุ่ม "ส่งข้อความ" ยังพาไป
   `#/chat/new/<uid>` เหมือนเดิมทุกบรรทัด (ดู `friendRow` / `friendButton`).

   สองอย่างที่แก้:
   1) **แถวคนบนมือถือ** — การ์ดแนะนำมีปุ่มสามปุ่ม (เพิ่มเพื่อน · ไม่สนใจ · บล็อก)
      และ `.rowacts` เดิมเป็น `flex: 0 0 auto` ⇒ ที่ 360px ปุ่มกินที่จนชื่อเหลือ
      ตัวอักษรเดียว. ปุ่มจึงตกลงบรรทัดของตัวเองชิดขวาแทน (มือถือมาก่อน).
   2) **รายการติดกันมีเส้นคั่น** เหมือน `.list.chatlist` และ `.view.v3-notifs`
      แทนการ์ดมนลอยเว้นห่างทุกแถว. สโคปใต้ `.list.v3-rows` เท่านั้น
      (friends.js ใส่ให้เฉพาะรายการคน) รายการอื่นทั้งแอปไม่ขยับ.
   ========================================================================== */

/* ---------- 1. แถวคน: ปุ่มไม่บีบชื่อ ---------- */
.row.personrow .rowacts { flex-wrap: wrap; justify-content: flex-end; row-gap: var(--sp-3); }
@media (max-width: 519px) {
  .row.personrow { flex-wrap: wrap; }
  /* เต็มบรรทัดของตัวเอง ชิดขวา — ชื่อกับอวาตาร์ได้ความกว้างเต็มบรรทัดบน */
  .row.personrow .rowacts { flex: 1 0 100%; padding-top: var(--sp-3); }
  .row.personrow .l1, .row.personrow .l2 { white-space: normal; }
  .row.personrow .l1 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
}

/* ---------- 2. รายการคนติดกัน (เหตุผลเดียวกับ `.list.chatlist`) ---------- */
/* เต็มขอบ ไม่มีมุมมน — ตรงกับ `.list.chatlist` และ `.view.v3-notifs` ที่ขึ้นไปแล้ว
   (`.view` ไม่มี padding แนวนอน) จึงไม่มีรายการรูปแบบที่สามในแอปนี้ */
.list.v3-rows {
  gap: 0; padding: 0;
  background: var(--s1);
  border-block: 1px solid var(--line);
}
.list.v3-rows > .row {
  background: transparent;
  border: 0;
  border-radius: 0;
  border-block-end: 1px solid var(--line);
  padding-block: 10px;
  padding-inline: 14px;
}
.list.v3-rows > .row:last-child { border-block-end: 0; }
.list.v3-rows > .row:hover { background: var(--hover); }
.list.v3-rows .av-wrap .online { border-color: var(--s1); }
/* หมายเหตุท้ายสารบัญซีรีส์ ("เลขตอนอาจข้ามได้…") อยู่ในรายการเดียวกันแต่ไม่ใช่
   `.row` ⇒ ไม่ได้ padding จากกฎข้างบน ถ้าไม่เติมจะไปชนขอบจอ */
.list.v3-rows > .epnote { margin: 0; padding: 10px 14px; border-block-start: 1px solid var(--line); }

/* ==========================================================================
   แท็ก · อัลบั้ม · ค้นพบ · ตั้งค่า · เข้าสู่ระบบ — ภาษา v3 (2026-08-14)
   วางท้ายไฟล์เท่านั้น: ทุกกฎในบล็อกนี้ "ทับ" ของเดิม **ไม่มี selector ใดถูกลบ**
   (`.album-*` บรรทัด 4541–4583 · `.auth *` บรรทัด 1724–1815 · `.row.tagcomment`
    บรรทัด 4485 ยังอยู่ครบและยังทำงานทุกข้อ).

   ใช้คลาสที่ขึ้นไปแล้วซ้ำทั้งหมด — `.ptabs` (แท็บขีดใต้) · `.list.v3-rows`
   (แถวติดกันมีเส้นคั่น) · `.chip` — จึงไม่มีแท็บ/รายการ/ชิปเวอร์ชันที่สองในแอปนี้.
   คลาสใหม่มีเพียงสิ่งที่ไม่เคยมีมาก่อนจริง ๆ: หัวหน้าแท็ก (`.taghead`) และ
   การ์ดกลุ่มตั้งค่า (`.k3card`).
   ========================================================================== */

/* ---------- 1. หน้าแท็ก (tag.js) ---------- */
/* v3 `.explore-hero-card`: แถบไล่เฉดเต็มความกว้าง มี `#` เป็นตราของหน้า.
   ตัวอักษรบนแถบเป็น #fff ตายตัว เพราะพื้นเป็นไล่เฉดแบรนด์ทั้งในโหมดสว่างและมืด
   — ไม่ใช่สีที่ผูกกับ accent ของผู้ใช้ (นี่คือพื้น ไม่ใช่สถานะ). */
.taghead {
  display: flex; align-items: center; gap: var(--sp-5);
  padding: 18px var(--sp-6);
  background: var(--brand);
  color: #fff;
}
.taghead-mark {
  flex: 0 0 auto;
  display: inline-grid; place-items: center;
  width: 46px; height: 46px; border-radius: var(--r-lg);
  background: rgba(255, 255, 255, .18);
  font-size: 24px; font-weight: 900; line-height: 1;
}
.taghead-main { min-width: 0; }
.taghead h3 {
  margin: 0; font-size: var(--fs-h); font-weight: 800; letter-spacing: -.01em;
  overflow-wrap: anywhere;                 /* แท็กยาว ๆ ไม่มีช่องว่างให้ตัดบรรทัด */
}
.taghead-meta { margin-top: 2px; font-size: var(--fs-sm); opacity: .92; }
.taghead-meta[hidden] { display: none; }
/* ป้าย "ถูกผู้ดูแลปิดกั้น" อยู่นอกแถบไล่เฉด — เป็นคำเตือน ไม่ใช่หัวเรื่อง */
.taghead-note { padding: var(--sp-4) var(--sp-6) 0; }
.taghead-note .chip { white-space: normal; text-align: start; }
@media (max-width: 519px) {
  .taghead { padding: 14px var(--sp-6); gap: var(--sp-4); }
  .taghead-mark { width: 40px; height: 40px; font-size: 21px; }
}

/* แถวความคิดเห็นในรายการ `.list.v3-rows`: `.row` ปกติเป็น flex แนวนอน แต่แถวนี้
   เป็นข้อความหลายบรรทัดเต็มความกว้าง (`.row.tagcomment .l1/.l2` เป็น block
   อยู่แล้วที่บรรทัด 4485) — ที่ต้องเติมคือปุ่ม/แบนเนอร์ที่อยู่ใน list เดียวกัน
   ซึ่งไม่ใช่ `.row` จึงไม่ได้ padding จากกฎของ `.list.v3-rows` */
.list.v3-rows > .btn,
.list.v3-rows > .banner,
.list.v3-rows > .state { margin: 10px 14px; }
.list.v3-rows > [data-more] { display: block; width: calc(100% - 28px); }

/* ---------- 2. ค้นพบ/เทรนด์ (trending.js) ---------- */
/* บรรทัดอธิบายใต้หัวข้อ "คนกำลังสนใจ" อยู่นอก `.list` จึงไม่มีระยะขอบของตัวเอง
   — ก่อนหน้านี้ชนขอบจอบนมือถือ */
.trending > .dim.small { padding: 0 var(--sp-6) var(--sp-3); }
/* รายการติด ๆ กันหลายชุดในหน้าเดียว: เว้นให้หัวข้อถัดไปไม่ติดเส้นล่างของชุดก่อน */
.trending > .list.v3-rows + .section-label { padding-top: var(--sp-6); }
/* ชิปช่วงเวลาของแฮชแท็ก (ชั่วโมงนี้ / วันนี้ / 7 วัน) อยู่ระหว่างหัวข้อกับรายการ */
.trending > .chiprow { padding: var(--sp-3) var(--sp-6) var(--sp-4); }

/* ---------- 3. อัลบั้ม (album.js) ---------- */
/* ปกที่ยังไม่มีรูป = ไอคอนกลางกรอบ ไม่ใช่ช่องเทาเปล่าที่อ่านเหมือนรูปโหลดไม่ขึ้น
   (เหตุผลเดียวกับ `.bizhead > .cover.noimg`) */
.album-cover { position: relative; border: 1px solid var(--line); }
.album-cover.noimg {
  display: grid; place-items: center; font-size: 30px; line-height: 1;
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--a1) 14%, var(--s3)),
    color-mix(in srgb, var(--a2) 18%, var(--s3)));
}
/* จำนวนรูป/การมองเห็นเป็นป้ายบนปก — บรรทัดข้อความใต้ปกยังอยู่ครบ ไม่ได้ย้ายไปไหน
   (ป้ายบนรูปอ่านไม่ได้เมื่อรูปสว่างจัด จึงต้องเหลือข้อความจริงไว้ใต้ปกเสมอ) */
.album-count, .album-vis {
  position: absolute; bottom: 6px;
  padding: 2px 7px; border-radius: var(--r-pill);
  background: color-mix(in srgb, #000 58%, transparent); color: #fff;
  font-size: var(--fs-micro); font-weight: 700; line-height: 1.5;
}
.album-count { inset-inline-end: 6px; }
.album-vis   { inset-inline-start: 6px; }
.album-card:hover .album-cover { border-color: color-mix(in srgb, var(--a1) 55%, var(--line)); }
.album-card:hover .album-cover img { transform: scale(1.03); }
.album-cover img { transition: transform .18s ease; }
/* v3 `.media-grid` = ช่องไฟ 6px ชิด ๆ กัน ไม่ใช่ตารางห่าง */
.album-photogrid { gap: 6px; }
.album-photo { border: 1px solid var(--line); }

/* ---------- 4. ตั้งค่า (account.js) — v3 `.k3-card` ---------- */
/* หัวการ์ด (ไอคอน + ชื่อกลุ่ม + คำอธิบาย) แล้วเนื้อในเป็น `.list.v3-rows`.
   การ์ดเต็มขอบบนมือถือ (ไม่มีมุมมน) และมีขอบ/มุมมนเมื่อจอกว้างพอ — เหมือน
   `.list.chatlist` ที่เต็มขอบด้วยเหตุผลเดียวกัน: 360px ไม่มีที่ให้เสีย 32px
   ไปกับระยะขอบซ้ายขวาของการ์ด */
.k3card {
  background: var(--s1);
  border-block: 1px solid var(--line);
  margin-block-start: var(--sp-5);
}
.k3card:first-child { margin-block-start: 0; }
.k3card-head {
  display: flex; align-items: flex-start; gap: var(--sp-4);
  padding: 12px var(--sp-6);
  border-block-end: 1px solid var(--line);
}
.k3card-icon {
  flex: 0 0 auto; display: inline-grid; place-items: center;
  width: 34px; height: 34px; border-radius: var(--r-md);
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--a1) 16%, transparent),
    color-mix(in srgb, var(--a2) 22%, transparent));
  font-size: 17px; line-height: 1;
}
.k3card-title { font-size: var(--fs-row); font-weight: 700; color: var(--ink); }
.k3card-sub { margin-top: 1px; font-size: var(--fs-micro); color: var(--dim); }
/* รายการในการ์ดไม่ต้องมีเส้นบน/ล่างซ้ำกับขอบการ์ดเอง */
.k3card > .list.v3-rows { border-block: 0; }
.k3card > .list.v3-rows + .list.v3-rows { border-block-start: 1px solid var(--line); }
/* ข้อความประกอบและปุ่มในการ์ด (ส่วน "ลบบัญชี") ต้องมีระยะขอบของตัวเอง —
   ในการ์ดไม่มี padding แนวนอน เพราะรายการต้องเต็มขอบ */
.k3card > .hint, .k3card > .err { margin: var(--sp-4) var(--sp-6) 0; }
.k3card > .row.end { padding: var(--sp-5) var(--sp-6); }
.k3card > .state { margin: var(--sp-4) var(--sp-6); }
@media (min-width: 640px) {
  .k3card {
    margin-inline: var(--sp-6); border: 1px solid var(--line);
    border-radius: var(--r-lg); overflow: hidden;
    box-shadow: var(--card-shadow);
  }
}

/* ---------- 5. หน้าเข้าสู่ระบบ (auth.js) — หน้าตาเท่านั้น ---------- */
/* ★ ไม่มีบรรทัดใดในบล็อกนี้แตะตรรกะ: auth.js ไม่ถูกแก้แม้แต่ตัวอักษรเดียว ★
   v3 วางการ์ดเข้าสู่ระบบบนพื้นเรืองแสงจาง ๆ แทนพื้นทึบ. ใช้ `radial-gradient`
   บนพื้นหลังของ `.auth` เอง ⇒ ไม่มี element ใหม่ ไม่มีเลเยอร์ทับปุ่มใด. */
.auth {
  background:
    radial-gradient(60% 42% at 18% 6%, color-mix(in srgb, var(--a1) 16%, transparent), transparent 70%),
    radial-gradient(52% 38% at 88% 92%, color-mix(in srgb, var(--a2) 16%, transparent), transparent 72%),
    var(--bg);
}
/* มือถือมาก่อน: 24px รอบด้าน + การ์ด padding 24px = เหลือ 264px ที่ 360px
   ⇒ ที่จอแคบให้การ์ดกินเต็มกว้างขึ้น */
@media (max-width: 419px) {
  .auth { padding: 16px 12px; }
  .auth .card { padding: var(--sp-6) 18px; border-radius: var(--r-lg); }
}
/* ตราของแอปได้วงเรืองแบรนด์เหมือนปุ่มหลัก — v3 ใช้แสงนี้เป็นเครื่องหมายของ
   "นี่คือของหลักบนหน้าจอนี้" */
.auth .mark { box-shadow: var(--brand-glow); }
.auth h1 { font-weight: 800; letter-spacing: -.01em; }
/* ปุ่มหลัก (เข้าสู่ระบบ / สมัคร) เป็นปุ่มเดียวที่ต้องเด่นบนหน้านี้.
   `.gbtn` ของ Google ไม่โดนกฎนี้ (มันไม่ใช่ `.btn`) ตามกฎแบรนด์ของ Google
   และปุ่ม `.ghost` ก็ไม่โดน — เงาอยู่บนปุ่มหลักเท่านั้น */
.auth .btn.block:not(.ghost) { box-shadow: var(--brand-glow); }
.auth .btn.block:not(.ghost):disabled { box-shadow: none; }

/* ==========================================================================
   สมุดหน้าเหลืองธุรกิจ (`directory.js`, #/directory) — ภาษา v3
   ทุกกฎสโคปใต้ `.view.directory` จึงไม่มีหน้าอื่นขยับตาม.
   ========================================================================== */
.view.directory [hidden] { display: none; }
.dirhero { padding: var(--sp-4) var(--sp-6) 0; }
.dirhero h1 { margin: 0 0 4px; font-size: 20px; font-weight: 700; line-height: 1.25; color: var(--ink); }
.dirhero p { margin: 0; font-size: var(--fs-micro); line-height: 1.5; color: var(--dim); }
.dirsearch { display: flex; flex-wrap: wrap; gap: 6px; margin-top: var(--sp-5); }
.dirsearch .field { flex: 1 1 180px; min-width: 0; }
.dirsearch .btn   { flex: 0 0 auto; }
.view.directory .chiprow.v3-catbar { margin-top: var(--sp-5); }
.view.directory .chip.pick .dirn {
  margin-inline-start: 4px; font-size: var(--fs-micro); font-weight: 700;
  color: var(--dim2); font-variant-numeric: tabular-nums;
}
.view.directory .chip.pick.on .dirn { color: inherit; }
.dircount { margin: var(--sp-4) var(--sp-6) 0; }
.dirgrid { display: grid; grid-template-columns: 1fr; gap: var(--sp-5); padding: var(--sp-5) var(--sp-6) var(--sp-4); }
@media (min-width: 700px) { .dirgrid { grid-template-columns: 1fr 1fr; } }
.dirgrid > .dircard {
  margin-block-start: 0;
  display: flex; flex-direction: column;
  border: 1px solid var(--line); border-radius: 16px; overflow: hidden;
  text-decoration: none; color: inherit;
}
.dirgrid > .dircard:hover { background: var(--hover); }
.dirgrid > .dircard:focus-visible { outline: 2px solid var(--a2); outline-offset: 2px; }
.dircover { position: relative; aspect-ratio: 16 / 9; background: var(--s3); }
.dircover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.dircover-ph { position: absolute; inset: 0; display: grid; place-items: center; font-size: 34px; }
.dircover .dirstatus {
  position: absolute; inset-block-end: var(--sp-4); inset-inline-start: var(--sp-4);
  max-width: calc(100% - var(--sp-6)); backdrop-filter: blur(6px);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dirbody { display: flex; flex-direction: column; gap: 4px; padding: 10px var(--sp-5) var(--sp-5); }
.dirtop  { display: flex; align-items: flex-start; gap: var(--sp-4); }
.dirtop > .grow { flex: 1 1 auto; min-width: 0; }
.dirname {
  margin: 0; font-size: var(--fs-row); font-weight: 700; color: var(--ink);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dirmeta {
  margin-top: 1px; font-size: var(--fs-micro); color: var(--dim);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dirrate { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; line-height: 1.2; }
.dirrate b { font-size: var(--fs-row); font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; }
.dirstars { font-size: 11px; color: var(--amber); letter-spacing: -0.5px; }
.dirrate small { font-size: 10px; color: var(--dim2); }
.dirrate.none small { font-size: var(--fs-micro); }
.dirdesc {
  margin: 2px 0 0; font-size: var(--fs-micro); line-height: 1.5; color: var(--dim);
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
}
.dirgo { margin-top: 6px; font-size: var(--fs-micro); font-weight: 700; color: var(--a2); }
html[dir="rtl"] .dirgo { direction: rtl; }
.dirpager {
  display: flex; align-items: center; justify-content: center; gap: var(--sp-5);
  padding: var(--sp-4) var(--sp-6) var(--sp-6);
}
.dirpage { font-size: var(--fs-micro); color: var(--dim); font-variant-numeric: tabular-nums; }

/* ── หางาน / สมัครงาน (jobs.js) ─────────────────────────────────────────
   ใช้คลาสที่ขึ้นไปแล้วซ้ำทั้งหมด — `.dirhero`/`.dirsearch`/`.dirgrid`/`.dircount`
   · `.ptabs` · `.chiprow.v3-catbar` · `.k3card` · `.state`.
   สีสถานะมาจาก `.chip.amber/.blue/.sky/.purple/.green/.red` ไม่มีค่าสีดิบที่นี่ */
.view.jobs .ptabs { margin-block-start: var(--sp-5); }
.view.jobs .chiprow.v3-catbar { margin-top: var(--sp-4); }
.jobfilters[hidden] { display: none; }
.jobfilterrow {
  display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4);
  padding: var(--sp-4) var(--sp-6) 0;
}
@media (min-width: 700px) { .jobfilterrow { grid-template-columns: repeat(4, 1fr); } }
.jobfield { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.jobfield > span {
  font-size: var(--fs-micro); font-weight: 600; color: var(--dim);
  margin-inline-start: 2px;
}
.jobfield .field { width: 100%; min-width: 0; }
.jobhint { margin: var(--sp-4) var(--sp-6) 0; }
.jobhint.warn { color: var(--amber); font-weight: 600; }
.jobhint[hidden] { display: none; }
.dirgrid > .jobcard {
  margin-block-start: 0;
  display: flex; flex-direction: column; gap: 6px;
  padding: 10px var(--sp-5) var(--sp-5);
  border: 1px solid var(--line); border-radius: 16px;
  text-decoration: none; color: inherit;
}
.dirgrid > .jobcard:hover { background: var(--hover); }
.dirgrid > .jobcard:focus-visible { outline: 2px solid var(--a2); outline-offset: 2px; }
.jobtop { display: flex; align-items: flex-start; gap: var(--sp-4); }
.jobtop > .grow { flex: 1 1 auto; min-width: 0; }
.jobkind { flex: 0 0 auto; }
.jobfacts { display: flex; flex-wrap: wrap; gap: 4px var(--sp-5); margin-top: 2px; }
.jobfact { font-size: var(--fs-micro); color: var(--dim); }
.jobfact.pay { font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; }
.jobtags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
.jobtags .chip { padding: 2px 8px; font-size: var(--fs-micro); font-weight: 600; }
.jobclosed { font-size: var(--fs-micro); font-weight: 700; color: var(--red); }
.jobappstate {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--sp-4); margin-top: 2px;
}
.jobdetailcard { padding: 12px var(--sp-6) var(--sp-5); }
.jobdetailtitle { margin: 0; font-size: 19px; font-weight: 700; line-height: 1.3; color: var(--ink); }
.jobfactrows { display: flex; flex-direction: column; gap: 5px; margin-top: var(--sp-5); }
.jobfactrow {
  display: flex; align-items: flex-start; gap: var(--sp-4);
  font-size: var(--fs-row); color: var(--ink);
}
.joblabel { margin: var(--sp-5) 0 4px; font-size: 13px; font-weight: 700; color: var(--dim); }
.jobbody { margin: 0; font-size: var(--fs-row); line-height: 1.6; color: var(--ink); white-space: pre-wrap; }
.jobactions { display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); }
.jobactions > .btn { width: 100%; }
.jobapply { display: flex; flex-direction: column; gap: var(--sp-5); }
.jobapply > .btn { width: 100%; }

/* ==========================================================================
   หน้าเข้าสู่ระบบ — รูปทรงจาก demo/social/v3 (login/register/logout)
   "รูปทรงห้ามเปลี่ยน แต่เพิ่มได้": แถวแบรนด์ + สวิตช์ธีมบนการ์ด + ทางเข้าอยู่บน
   + ช่องข้อความที่จองที่ไว้. ปุ่ม Google ใช้ `.gbtn` เดิม — ไม่มี brand-glow ทับ.
   ========================================================================== */
.auth .card.auth-card { position: relative; }
.auth-theme {
  position: absolute; top: 10px; inset-inline-end: 10px;
  width: 34px; height: 34px; display: inline-grid; place-items: center;
  border-radius: 999px; border: 1px solid var(--line);
  background: var(--s2); color: var(--dim); cursor: pointer; padding: 0;
  transition: color .12s ease, border-color .12s ease, background-color .12s ease;
}
.auth-theme:hover { color: var(--ink); background: var(--s3); }
.auth-theme:focus-visible { outline: 2px solid var(--a2); outline-offset: 2px; }
.auth-theme svg { width: 17px; height: 17px; }
.auth-brand {
  display: flex; align-items: center; justify-content: center; gap: var(--sp-4);
  margin: 0 0 var(--sp-5);
}
.auth .auth-brand .mark { width: 40px; height: 40px; margin: 0; flex: 0 0 auto; }
.auth-word {
  font-size: 21px; font-weight: 800; letter-spacing: -.02em; color: var(--ink); line-height: 1;
}
.auth [data-oauth] .authways { margin-top: var(--sp-2); }
.auth .authways .btn.ghost svg,
.auth .authways .gbtn .gmark { width: 18px; height: 18px; flex: 0 0 auto; }
.auth .gbtn, .auth .gbtn:disabled { box-shadow: none; }
.pw-meter { display: flex; gap: 4px; margin-top: 6px; }
.pw-meter > i { flex: 1 1 0; height: 4px; border-radius: 999px; background: var(--line); }
.pw-meter[data-score="1"] > i:nth-child(-n+1),
.pw-meter[data-score="2"] > i:nth-child(-n+2) { background: #EF4444; }
.pw-meter[data-score="3"] > i:nth-child(-n+3) { background: #F59E0B; }
.pw-meter[data-score="4"] > i { background: #22C55E; }
.authcheck {
  display: flex; align-items: flex-start; gap: var(--sp-4);
  font-size: var(--fs-sm); color: var(--dim); line-height: 1.45; cursor: pointer;
}
.authcheck input { width: 17px; height: 17px; margin: 1px 0 0; flex: 0 0 auto; accent-color: var(--a2); }
.authcheck a { color: var(--a2); font-weight: 600; }
.auth-msg-slot { min-height: 8px; margin-top: var(--sp-5); }
.auth-msg-slot .err, .auth-msg-slot .ok, .auth-msg-slot .note { margin-bottom: 0; }
.auth-msg-slot > div > p + p { margin-top: var(--sp-3); }
@media (max-width: 380px) {
  .auth-theme { top: 8px; inset-inline-end: 8px; width: 30px; height: 30px; }
  .auth-word { font-size: 19px; }
}
