/* ---------------------------------------------------------------------------
   Leaf Lohas — 老葉的児子
   One stylesheet, no build step, no framework.

   Token names and values come from design/leaflohas.css and the UI kit, so a
   future design revision can be read against this file line for line. Deep
   green is every action, amber is money and scarcity, red is destructive or
   blocked, and the seal red belongs to the brand mark alone.

   The palette is complete in both themes. What each surface does with that is
   a decision, not an accident: the customer app and the back office both pin
   dark with data-theme="dark" on <html>, which is what Gan asked for; the
   customer's own copy of a receipt, ticket or order confirmation pins light,
   because that is the thing they forward, screenshot and print, and a printer
   will not lay down a black page.

   Type is the design folder's, with one deliberate departure. Traditional
   Chinese leads every stack: a Simplified-first face renders 異體字 wrongly and
   the brand is written 児 on purpose. IBM Plex Mono carries every number, time
   and reference, because columns that do not line up look broken on a bill.
   --------------------------------------------------------------------------- */

/* ---------------------------------------------------------------------------
   1 — Tokens

   These are the design folder's names and values (design/leaflohas.css and the
   UI kit's token table), so a future design revision can be read against this
   file line for line.

   Three palette blocks, on purpose and not to be collapsed:
     :root                                     the light palette
     prefers-color-scheme + :not([data-theme="light"])   follows the OS
     [data-theme="dark"]                       an explicit choice wins

   The public app and the back office both stamp data-theme="dark"; the
   customer-facing receipt layout stamps data-theme="light". The three blocks
   are still all needed: the light one is what the receipt resolves against,
   and the media query is what a future "follow the system" setting would use
   without touching a single component rule.

   Three tokens the reference stylesheet uses but never declares are defined
   here, because a missing custom property does not fall back — it makes the
   whole declaration invalid:
     --on-accent    declared only inside the dark media block upstream, so in
                    light and under the manual dark toggle every .btn lost its
                    foreground colour and inherited one instead. The UI kit
                    names this exact bug in its own token table.
     --line-strong  used by the calendar day capacity bar.
     --ground       used by the kit's page background.
   --------------------------------------------------------------------------- */

:root {
  --ground:   #F6F8F4;
  --app:      #FBFCFA;
  --card:     #FFFFFF;
  --soft:     #F0F4EE;
  --ink:      #17201B;
  --ink-2:    #41504A;
  /* 4.46:1 against --card before this, which is a fail — and the light theme is
     the receipt, the ticket and the order confirmation: the documents a customer
     forwards, screenshots and prints. 5.69:1 now. */
  --muted:    #5C6A62;
  /* Edges you can see.
     ------------------------------------------------------------------------
     These measured 1.28:1 (--line on --card) and 1.57:1 (--line-2). An older
     eye reads the words inside a box perfectly well and cannot find the box.
     Thirteen boundary pairs were under 3:1 and the worst of them was the ×
     that deletes a line off a bill.

     Two targets, not one, because the standard has two jobs in mind. Anything
     that tells you where a *control* is or what state it is in — an input
     border, a stepper, a checkbox, that × — is WCAG 1.4.11 and owes 3:1;
     `--line-2` and `--line-strong` clear it against card, app and soft with a
     little room to spare: 3.22:1 at the worst pairing — against `--soft` —
     and 3.58:1 against a card. The first pass landed one of the six at 3.01:1,
     which is a pass that the next colour tweak turns into a fail.
     A card edge or a row divider is a container, not a control, and 3:1 there
     turns the interface into a wireframe of hard boxes. `--line` goes to a
     measured 2.15:1 at its worst pairing — against `--soft` — and 2.4:1
     against a card: plainly visible, still a hairline, and with enough room
     that the next colour adjustment does not land it on the line. */
  --line:     #9BAD90;
  --line-2:   #768E6F;
  --line-strong: #768E6F;

  --accent:    #2E6B57;
  --accent-2:  #E2EFE7;
  --accent-hi: #24563F;   /* hover: darker on light, brighter on dark */
  --on-accent: #FFFFFF;
  --celadon:   #6BAE87;   /* decorative fills only */

  /* Money on a printed receipt. 4.01:1 on --amber-2 before, 4.97:1 now. */
  --amber:    #8A5D18;
  --amber-2:  #F6EEDE;
  --red:      #9B3D33;
  --red-2:    #F8E9E6;

  /* The seal. Brand mark only — never a button, never a border. */
  --seal:     #E1272E;

  --radius:    16px;
  --radius-sm: 12px;
  --tabh:      62px;
  --sh: 0 1px 2px rgba(23,32,27,.06), 0 10px 30px -18px rgba(23,32,27,.35);

  /* Two faces doing two jobs, in this order on purpose: the Latin face is
     first so it carries the English and the numerals, and the Chinese falls
     through to the face behind it. One typeface trying to do both scripts
     does one of them badly.

     `LL Round` is 阿里妈妈方圆体, subsetted and self-hosted — see the @font-face
     block below. `Yuanti SC` and `PingFang SC` sit behind it so an Apple
     device still looks right while the webfont loads, or if it never does.

     These asked for Traditional faces — Noto Sans TC, PingFang TC, Microsoft
     JhengHei — for months after the interface became Simplified, so 简体 text
     was being drawn with 繁體 shapes on every Mac. */
  --sans: "Google Sans Flex", "LL Round", "Yuanti SC", "PingFang SC",
          "Noto Sans SC", "Microsoft YaHei", -apple-system, BlinkMacSystemFont,
          "Segoe UI", Roboto, sans-serif;

  /* The brand, and nothing else. 老葉的児子 is written with 葉 and the variant
     児, neither of which exists in the rounded face — so if the name were set
     in the body face those two characters would drop to a fallback and the
     shop's own name would render half in one typeface and half in another. */
  /* **One face for every word, front of house and back.**

     Gan circled a heading and a line of body text on the same card and asked
     for one of them: 「i want standardize all front end backend to be the font
     in blue」. He was looking at two typefaces doing the same job on one screen
     — a serif for titles and the rounded sans for everything else — and a shop
     with two voices reads as two shops.

     So the title face is now the body face. The token is kept rather than
     replaced at all eighteen call sites, because that is one line to change if
     a display face is ever wanted again, and eighteen to hunt down if it is not.

     What is deliberately *not* folded in is `--mono`. Figures, times, money and
     references stay monospaced: RM 5.00 and RM 158.00 have to line up under
     each other in a column, and R-202608-003 has to be readable aloud down a
     phone. That is a different job, not a different taste. */
  --serif: "Google Sans Flex", "LL Round", "Yuanti SC", "PingFang SC",
           "Noto Sans SC", "Microsoft YaHei", -apple-system, BlinkMacSystemFont,
           "Segoe UI", Roboto, sans-serif;

  /* **The shop's name, and nothing else.**

     老葉的児子 is written with 葉 and the variant 児, and neither exists in the
     rounded face. Set in the interface stack, those two characters drop to a
     system fallback and the name renders three characters in one typeface and
     two in another — which is worse than any inconsistency Gan was asking me to
     remove, because it happens inside the name itself.

     So the old stack survives here, for five characters. Unifying the interface
     was the request; breaking the shop's own name was not. */
  --brand: "Noto Serif SC", "Songti SC", "Source Han Serif SC", Georgia, serif;

  --mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;

  /* Latin and Chinese want different amounts of air. CJK glyphs fill their box
     edge to edge and need loosening; Latin loosened by the same amount starts
     to fall apart. These are the two values, applied separately. */
  --track:      .015em;
  --track-cjk:  .04em;
  --track-head: .05em;

  color-scheme: light;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ground:   #0C110E;
    --app:      #141A17;
    --card:     #1B231E;
    --soft:     #212B25;
    --ink:      #E7EEE8;
    --ink-2:    #C4D0C7;
    --muted:    #8DA096;
    /* 2.17:1 at worst for container hairlines, 3.22:1 at worst for anything
       that marks a control. See the note on the light tokens above. */
    --line:     #4D6054;
    --line-2:   #667B6F;
    --line-strong: #667B6F;

    --accent:    #6FC0A0;
    --accent-2:  #1D2E27;
    --accent-hi: #8FD4B8;
    --on-accent: #0C110E;
    --celadon:   #3E8A63;

    --amber:    #DCAE5F;
    --amber-2:  #2B2419;
    --red:      #E08A7C;
    --red-2:    #31201D;
    --seal:     #F2585E;

    --sh: 0 1px 2px rgba(0,0,0,.5), 0 10px 30px -18px rgba(0,0,0,.9);

    color-scheme: dark;
  }
}

:root[data-theme="dark"] {
  --ground:   #0C110E;
  --app:      #141A17;
  --card:     #1B231E;
  --soft:     #212B25;
  --ink:      #E7EEE8;
  --ink-2:    #C4D0C7;
  --muted:    #8DA096;
  /* Same two targets as the light theme, and measured: 2.17:1 at worst for a
     container hairline, 3.22:1 at worst for the edge of anything you press. */
  --line:     #4D6054;
  --line-2:   #667B6F;
  --line-strong: #667B6F;

  --accent:    #6FC0A0;
  --accent-2:  #1D2E27;
  --accent-hi: #8FD4B8;
  --on-accent: #0C110E;
  --celadon:   #3E8A63;

  --amber:    #DCAE5F;
  --amber-2:  #2B2419;
  --red:      #E08A7C;
  --red-2:    #31201D;
  --seal:     #F2585E;

  --sh: 0 1px 2px rgba(0,0,0,.5), 0 10px 30px -18px rgba(0,0,0,.9);

  color-scheme: dark;
}

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

/* `hidden` must actually hide.
   The browser's own rule for it is `display: none`, and *any* author
   `display` beats a browser default — so `.btn { display: inline-flex }`
   quietly made `<button hidden>` visible. That is how the scanner ended up
   showing 用相機掃描 and 停止 at the same time. One rule, once, rather than a
   `[hidden]` override next to every component that sets display. */
[hidden] { display: none !important; }

/* ---------------------------------------------------------------------------
   The reading size

   There was no `html { font-size }` rule at all. The root therefore sat at the
   browser's 16px while `body` was set to a flat `15px`, so every `rem` in this
   file was sized against a number 6.7% larger than the text anybody was
   actually reading. Nothing looked broken; the scale was simply keyed to a
   measurement that appeared nowhere on screen.

   It is declared now, and the back office gets a bigger one. Gan's operators
   are not young:

     「how to make it more user friendly as my client's are elderly and might
      not technology friendly」

   18px against 16px is a 12.5% lift on every `rem` behind the counter. It is
   safe to do it this way — and only this way — because **not one length in this
   stylesheet outside of type is expressed in `rem`**, and `tests/legibility.php`
   fails the build if one ever is — in here or in a view's `style` attribute. So
   the root moves the words and leaves the boxes where they were. Had spacing
   been in rem, this one line would have reflowed the entire application.

   The trade is real and accepted: a bookings list shows roughly a fifth fewer
   rows on a phone. Scrolling is cheap. Misreading a headcount is not.

   The customer side stays at the reader's own default. It is read once, by a
   stranger, on their own phone; the back office is read forty times a day by
   the same two people.

   Percentages and not pixels, deliberately. `html { font-size: 16px }` is the
   one declaration that overrides a reader who has raised the default font size
   in their browser — and in a release whose entire purpose is people who have
   done exactly that, hard-coding the root would take the setting away from the
   readers it is for. `100%` is their number, whatever it is; `112.5%` is that
   number and an eighth again, so 16 becomes 18 and 20 becomes 22.5.
   --------------------------------------------------------------------------- */
html { font-size: 100%; }
html.admin { font-size: 112.5%; }

body {
  margin: 0;
  background: var(--app);
  color: var(--ink);
  font-family: var(--sans);
  /* Was 15px flat, which is what put the body copy *below* the root the rest of
     the scale was measured from. 1rem so the two agree, and so the back office
     inherits its own reading size rather than a number typed here once. */
  font-size: 1rem;
  line-height: 1.8;
  letter-spacing: var(--track-cjk);
  word-spacing: .04em;
  -webkit-font-smoothing: antialiased;
}

/* Headings were a CJK serif at weight 600. Stacked down a page that reads as
   weight rather than as emphasis, and it was most of what made the interface
   feel heavy. They are now the body face at 500, which is enough contrast
   against 400 without shouting. */
h1, h2, h3 { font-family: var(--sans); font-weight: 500; line-height: 1.5; margin: 0 0 .7em;
             letter-spacing: var(--track-head); }
h1 { font-size: 1.45rem; }
h2 { font-size: 1.15rem; }
h3 { font-size: 1rem; }
p  { margin: 0 0 1em; }

a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
a:hover { color: var(--accent-hi); }

.wrap { max-width: 1080px; margin: 0 auto; padding: 0 20px; }
.wrap-narrow { max-width: 640px; margin: 0 auto; padding: 0 20px; }

/* --- Brand mark ---------------------------------------------------------- */
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: inherit; }
.brand__logo { width: 40px; height: 40px; flex: 0 0 40px; }
.brand__logo img, .brand__logo svg { width: 100%; height: 100%; display: block; }
.brand__logo--fallback {
  display: grid; place-items: center;
  background: var(--seal); color: #fff;
  border-radius: 8px;
  font-family: var(--brand); font-size: 22px; font-weight: 700;
}
.brand__text { display: flex; flex-direction: column; line-height: 1.2; }
.brand__name { font-family: var(--brand); font-size: 1.05rem; font-weight: 600; }
.brand__sub  { font-size: 0.875rem; color: var(--muted); letter-spacing: .08em; text-transform: uppercase; }

/* --- Header -------------------------------------------------------------- */
.site-header {
  background: var(--card);
  border-bottom: 1px solid var(--line);
  position: sticky; top: 0; z-index: 20;
}
.site-header__inner {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px 12px; flex-wrap: wrap;
  /* This element IS the .wrap, so its shorthand padding replaces .wrap's
     horizontal padding rather than adding to it. Declaring 12px 0 here left
     the header content flush against both screen edges. */
  padding: 12px 20px;
}

/* --- Admin nav ----------------------------------------------------------- */
.adminnav { display: flex; gap: 2px; flex-wrap: wrap; }
.adminnav a {
  /* 7px 12px before. The tools group beside this row grew by a whole pill in
     058, and at 1024px — a tablet propped by the till, in landscape — the
     header then wrapped to two rows and doubled in height. Four pixels a side
     across six links is 48px, and that is what buys the single row back at
     1024 **in Chinese**: 75.1px instead of 129.1. The English labels are
     longer and still wrap to two rows there, and at every desktop width; a
     129px header on a laptop is a cost worth paying and a 129px one on a
     tablet was not. */
  padding: 7px 8px; border-radius: var(--radius-sm);
  color: var(--ink-2); text-decoration: none; font-size: .92rem; white-space: nowrap;
}
.adminnav a:hover { background: var(--soft); color: var(--ink); }
.adminnav a[aria-current="page"] { background: var(--accent-2); color: var(--accent); font-weight: 500; }

/* --- Admin sub-tabs -------------------------------------------------------
   The second row: the pages inside the section you are in. A section with one
   page renders no row at all — a tab bar of one is the page title said twice.
   It scrolls sideways on a phone rather than wrapping, so the header height
   stays the same on every page.
   ------------------------------------------------------------------------- */
.subnav { border-top: 1px solid var(--line); background: var(--app); }

/* The row scrolls sideways rather than wrapping, so the header is the same
   height on every page. In English it overflows by about 150px on a phone —
   and a row that is cut off dead straight at the screen edge reads as a row
   that ends there. The fade says there is more, and costs no height. */
.subnav { position: relative; }
.subnav::after {
  content: ""; position: absolute; top: 0; right: 0; bottom: 0; width: 28px;
  pointer-events: none;
  background: linear-gradient(to right, transparent, var(--app));
}
@media (min-width: 760px) { .subnav::after { display: none; } }
.subnav__inner {
  display: flex; gap: 2px;
  overflow-x: auto; scrollbar-width: none;
  padding-top: 0; padding-bottom: 0;
}
.subnav__inner::-webkit-scrollbar { display: none; }
.subnav a {
  padding: 10px 12px; white-space: nowrap;
  color: var(--muted); text-decoration: none; font-size: .88rem;
  border-bottom: 2px solid transparent;
}
.subnav a:hover { color: var(--ink); }
.subnav a[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); font-weight: 500; }

/* Wrapping, because this row grew.
   ---------------------------------------------------------------------------
   It holds up to six things — the cron warning, the queued-message count, the
   upgrade notice, the language switch, 白天 / 晚上 and 登出 — and it was a
   single non-wrapping flex line. At 390px that came to 426px of content in a
   390px window: 56px of the back office hanging off the right edge of every
   admin page, with the sign-out button in it. */
.site-header__tools {
  display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 10px;
  /* `space-between` on the row above puts a single wrapped child at the *left*,
     which is where this group landed the moment it grew a third pill. This
     keeps it to the right whether it shares the first row or takes a second. */
  margin-left: auto;
}

/* On a phone the six sections live in the bottom bar, so the top row of the
   same six would be the same navigation twice. The sub-tabs stay: they are the
   only way to reach the second page of a section. */
@media (max-width: 759px) {
  .adminnav { display: none; }
  /* The bar's height is reserved on the body itself (see body:has(.tabbar)). */
}

/* --- Cards --------------------------------------------------------------- */
.card {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: none;
  padding: 20px;
  margin-bottom: 16px;
}
.card__title {
  font-family: var(--sans); font-size: .98rem; font-weight: 500;
  letter-spacing: var(--track-head);
  margin: 0 0 6px;
}
.card__hint { color: var(--muted); font-size: .88rem; margin: 0 0 16px; }

/* --- Folding cards --------------------------------------------------------
   A settings page is two jobs wearing one coat: particulars typed once when the
   business was registered, and rules changed as the shop learns. The first
   should be out of the way. <details> does this with no script, which matters
   under a strict CSP, and keeps working with JavaScript off.
   ------------------------------------------------------------------------- */
.card.fold { padding: 0; }
.fold__head {
  display: flex; align-items: center; gap: 10px;
  padding: 16px 20px; cursor: pointer;
  list-style: none; /* Firefox */
  user-select: none;
}
.fold__head::-webkit-details-marker { display: none; }  /* Safari */
.fold__head::after {
  content: ''; margin-left: auto; flex: 0 0 auto;
  width: 8px; height: 8px;
  border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform .16s ease;
}
.fold[open] > .fold__head::after { transform: rotate(-135deg) translate(-2px, -2px); }
.fold__head:hover { background: var(--soft); }
.fold__head .card__title { margin: 0; }
.fold[open] > .fold__head { border-bottom: 1px solid var(--line); }
.fold__body { padding: 18px 20px 4px; }
.fold__body > .card__hint { margin-top: -4px; }

@media (prefers-reduced-motion: reduce) { .fold__head::after { transition: none; } }

/* --- Settings groups ------------------------------------------------------ */
.setgroup { margin: 26px 0 10px; }
.setgroup:first-of-type { margin-top: 8px; }
.setgroup__title {
  font-family: var(--sans); font-size: 0.875rem; font-weight: 600;
  letter-spacing: .1em; text-transform: uppercase;
  color: var(--muted); margin: 0;
}
.setgroup__hint { color: var(--muted); font-size: 0.875rem; margin: 2px 0 0; }

.grid { display: grid; gap: 20px; }
@media (min-width: 760px) { .grid--2 { grid-template-columns: 1fr 1fr; } }

/* --- Stats --------------------------------------------------------------- */
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat__value { font-family: var(--serif); font-size: 2rem; font-weight: 600; line-height: 1.1; }
.stat__label { color: var(--muted); font-size: 0.875rem; }

/* --- Tables -------------------------------------------------------------- */
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { width: 100%; border-collapse: collapse; font-size: .93rem; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
th { font-weight: 600; color: var(--muted); font-size: 0.875rem; text-transform: uppercase; letter-spacing: .05em; }
tbody tr:last-child td { border-bottom: 0; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }

/* --- Forms --------------------------------------------------------------- */
.field { margin-bottom: 16px; }
.field label { display: block; font-size: .88rem; font-weight: 500; margin-bottom: 5px; color: var(--ink-2); }
.field .hint { font-size: 0.875rem; color: var(--muted); margin-top: 4px; }

/* The line that used to sit under every date field, repeating the date with
   its weekday, is gone. It was there because three dropdowns could not say the
   weekday themselves — but the chip can, so it does, and the field is one line
   shorter. See .dpick__dowlbl below. */

input[type="text"], input[type="email"], input[type="password"], input[type="number"],
input[type="time"], input[type="date"], input[type="search"], select, textarea {
  width: 100%;
  padding: 9px 11px;
  font: inherit; font-size: .95rem;
  color: var(--ink);
  background: var(--soft);
  border: 1px solid var(--line-2);
  border-radius: var(--radius-sm);
}
/* A file input renders its own button and filename and takes whatever width
   that needs — 261px at the old type size, 298 at the new one, inside a 252px
   card on a 320px screen. */
input[type="file"] { max-width: 100%; width: 100%; min-height: 44px; }
input:focus, select:focus, textarea:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-color: var(--accent);
}
textarea { min-height: 84px; resize: vertical; }

/* `align-items: flex-end` looked right until one field in a row had a hint
   under its input: the hint then lined up with the other inputs and shoved that
   field's label and box upward, so the row read as two rows. Aligning to the
   top lines the labels up and lets hints hang below, where they belong.
   The 180px basis is a phone decision — at 130px three fields stayed jammed
   side by side on a 390px screen. */
.field-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-start; }
.field-row .field { flex: 1 1 180px; margin-bottom: 0; }

/* A switch and its wording.
   ---------------------------------------------------------------------------
   Four of these publish the shop: 开放预约座位, 开放商店, 开放空间租借,
   开放活动门票. Twenty of the twenty-eight in the views are `<div class="check">`
   with a separate `<label for>` inside, and only eight are `<label class=
   "check">` — so a floor written as `label.check` reached under a third of them
   and missed every one that turns the public site on and off.

   It is on `.check` itself, whatever element that is, and on the label inside
   it, which is the part somebody aims at. */
.check { display: flex; align-items: center; gap: 9px; margin-bottom: 10px; min-height: 44px; }
.check input { flex: 0 0 auto; }
.check label { margin: 0; font-weight: 400; min-height: 44px; display: inline-flex; align-items: center; }

/* --- Buttons ------------------------------------------------------------- */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  padding: 9px 18px;
  font: inherit; font-size: .93rem; font-weight: 500;
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
  cursor: pointer;
  text-decoration: none;
  /* A button label is two or three characters in Chinese and wrapping it
     produces 存備 / 註, which reads as two words and looks broken. */
  white-space: nowrap;
  background: var(--accent); color: var(--on-accent);
}
.btn:hover { background: var(--accent-hi); color: var(--on-accent); }
/* A secondary button is a control, and 登出 is one of these on every screen in
   the back office. `--line` is the container hairline and is held at 2.2:1 on
   purpose; the edge of something you press owes 3:1, which is `--line-2`. The
   same swap is made below for every other bordered control that was drawing
   its edge from the wrong token. */
.btn--ghost { background: transparent; color: var(--ink); border-color: var(--line-2); }
.btn--ghost:hover { background: var(--soft); border-color: var(--accent); color: var(--ink); }
.btn--link { background: none; border: 0; color: var(--muted); padding: 4px 6px; font-size: 0.875rem; }
.btn--link:hover { color: var(--red); background: none; }
.btn--block { width: 100%; }

/* --- Flash / alerts ------------------------------------------------------ */
.alert { border-radius: var(--radius-sm); padding: 11px 14px; margin-bottom: 16px; font-size: .92rem; border: 1px solid transparent; }
.alert--success { background: var(--accent-2);   color: var(--accent);   border-color: currentColor; }
.alert--error   { background: var(--red-2);  color: var(--red);  border-color: currentColor; }
.alert--warn    { background: var(--amber-2); color: var(--amber); border-color: currentColor; }

/* --- Schedule ------------------------------------------------------------ */
.week { display: grid; gap: 8px; }
.week__day {
  display: grid; grid-template-columns: 84px 1fr; gap: 12px; align-items: baseline;
  padding: 9px 0; border-bottom: 1px solid var(--line);
}
.week__day:last-child { border-bottom: 0; }
.week__name { font-weight: 500; font-size: .93rem; }
.week__slots { display: flex; flex-wrap: wrap; gap: 6px; }
.slot {
  font-variant-numeric: tabular-nums;
  font-size: 0.875rem;
  padding: 3px 9px;
  background: var(--soft);
  border-radius: 999px;
  border: 1px solid var(--line-2);
  color: var(--ink-2);
}
.slot--closed { color: var(--muted); background: transparent; border-style: dashed; }

/* --- Badges -------------------------------------------------------------- */
.badge { display: inline-block; font-size: 0.875rem; padding: 2px 8px; border-radius: 999px; font-weight: 500; }
.badge--on  { background: var(--accent-2);  color: var(--accent); }
.badge--off { background: var(--soft);  color: var(--muted); }

/* --- Requirements list (installer) --------------------------------------- */
.req { list-style: none; padding: 0; margin: 0; }
.req li {
  display: flex; align-items: baseline; gap: 10px;
  padding: 7px 0; border-bottom: 1px solid var(--line); font-size: .92rem;
}
.req li:last-child { border-bottom: 0; }
.req__mark { flex: 0 0 18px; font-weight: 700; }
.req__ok   { color: var(--accent); }
.req__bad  { color: var(--red); }
.req__warn { color: var(--amber); }
.req__detail { margin-left: auto; color: var(--muted); font-family: var(--mono); font-size: 0.875rem; }
/* `035_imported_payments_backfill.sql` has nowhere to break, and on the upgrade
   screen — which is only ever read on a phone at the worst possible moment —
   it took the page with it. */
.req li .mono, .req__detail, .card td.mono { overflow-wrap: anywhere; min-width: 0; }

/* --- Bare pages (login, install) ----------------------------------------- */
.bare {
  min-height: 100vh;
  display: grid; place-items: center;
  padding: 32px 20px;
  background: var(--soft);
}
.bare__box { width: 100%; max-width: 460px; }
.bare__box--wide { max-width: 700px; }
.bare__head { text-align: center; margin-bottom: 22px; }
.bare__head .brand { justify-content: center; }

/* --- Footer -------------------------------------------------------------- */
.site-footer {
  margin-top: 48px; padding: 24px 0 40px;
  border-top: 1px solid var(--line);
  color: var(--muted); font-size: 0.875rem;
}

/* Centred, and stacked in the order a reader wants: the name they know you by,
   a line in your own voice, where to follow you, and the registration number
   last — it matters legally and to nobody else. */
.site-footer__inner {
  display: flex; flex-direction: column; align-items: center;
  gap: 10px; text-align: center;
}
.site-footer__brand {
  margin: 0; font-family: var(--brand);
  font-size: 1.15rem; font-weight: 600; color: var(--ink);
}
.site-footer__tag { margin: 0; color: var(--ink-2); }
.site-footer__legal {
  margin: 6px 0 0; font-size: 0.875rem; color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.social { display: flex; gap: 6px; }
.social__link {
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; height: 44px;              /* a real touch target, not a glyph */
  border: 1px solid var(--line-2); border-radius: 999px;
  color: var(--muted); text-decoration: none;
  transition: color .15s ease, border-color .15s ease;
}
.social__link:hover,
.social__link:focus-visible { color: var(--accent); border-color: var(--accent); }
.social__link svg { width: 19px; height: 19px; }



/* --- Hero (public) ------------------------------------------------------- */
.hero { padding: 56px 0 32px; text-align: center; }
.hero__mark { width: 84px; height: 84px; margin: 0 auto 18px; }
.hero__mark img { width: 100%; height: 100%; }
.hero h1 { font-size: 2.1rem; margin-bottom: .2em; }
.hero__tagline { color: var(--muted); font-size: 1.02rem; }

.muted { color: var(--muted); }
.small { font-size: 0.875rem; }
.mono  { font-family: var(--mono); font-size: 0.875rem; }
.mt-0 { margin-top: 0; }
.mb-0 { margin-bottom: 0; }
.nowrap { white-space: nowrap; }

/* --- Booking: day and session pickers ------------------------------------ */
.daylist, .sessionlist { display: grid; gap: 8px; }
@media (min-width: 520px) { .daylist { grid-template-columns: 1fr 1fr; } }

.daycard, .sessioncard {
  display: flex; align-items: baseline; gap: 10px;
  padding: 14px 16px;
  border: 1px solid var(--line-2);
  border-radius: var(--radius);
  background: var(--card);
  text-decoration: none; color: var(--ink);
}
a.daycard:hover, a.sessioncard:hover { border-color: var(--accent); color: var(--ink); }

.daycard__date, .sessioncard__time {
  font-family: var(--mono); font-size: 1.02rem; font-weight: 500;
  letter-spacing: 0; font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* --- Session times --------------------------------------------------------
   A session is one thing, so it reads as one line and never breaks across two.
   The start time is what a guest is choosing; the end time is a consequence of
   it, so it is quieter, and the dash is tight rather than a spaced en dash that
   invited a line break. `tabular-nums` keeps 09:30 and 11:00 the same width so
   a column of them lines up. -------------------------------------------------- */
.tspan { white-space: nowrap; font-variant-numeric: tabular-nums; }
.tspan .to { color: var(--muted); font-weight: 400; }
.tspan .to::before { content: '–'; margin: 0 1px; opacity: .6; }
.tmins { color: var(--muted); font-size: 0.875rem; font-variant-numeric: tabular-nums; }
.daycard__day { color: var(--muted); font-size: .88rem; }
.daycard__seats, .sessioncard__seats { margin-left: auto; font-size: 0.875rem; font-weight: 500; color: var(--accent); }

.daycard--out, .sessioncard--out { opacity: .55; background: transparent; border-style: dashed; }
.daycard--out .daycard__seats, .sessioncard--out .sessioncard__seats { color: var(--muted); }

/* --- Booking reference --------------------------------------------------- */
.refbox { text-align: center; margin: 8px 0 18px; }
.refbox__code {
  font-family: var(--mono); font-size: 1.6rem; font-weight: 600;
  letter-spacing: .08em; color: var(--accent);
}

/* --- Admin session capacity strip ---------------------------------------- */
.caps { display: grid; gap: 6px; }
.cap { display: flex; align-items: center; gap: 10px; font-size: .9rem; }
/* 92px was two characters short of 11:00–13:00, so every row wrapped. Sized in
   ch of the monospace face it actually renders in, and told never to break. */
.cap__time {
  font-family: var(--mono); flex: 0 0 auto; min-width: 11.5ch;
  white-space: nowrap; font-variant-numeric: tabular-nums;
}
.cap__bar { flex: 1 1 auto; height: 8px; border-radius: 999px; background: var(--soft); overflow: hidden; }
.cap__fill { height: 100%; background: var(--celadon); }
.cap__fill--full { background: var(--red); }
.cap__num { flex: 0 0 auto; color: var(--muted); font-variant-numeric: tabular-nums; }

/* --- Status pills --------------------------------------------------------- */
/* A bare .pill had no background, so any status without a modifier — packed,
   shipped, paid — rendered as loose text next to real pills. The neutral
   ground is the default; the modifiers colour it. */
/* **The status now has an edge, and the edge carries the colour.**
   ---------------------------------------------------------------------------
   The four fills measured 1.04–1.13:1 against the page. The *text* inside them
   was always fine — over 4.5:1, every one — but the shape holding it was not
   there. On a sun-washed phone at the counter a pill was a word floating in a
   faint smudge, and which smudge it was could not be told apart from the next
   one along.

   `currentColor` rather than a fourth set of hand-picked hex values: the pill's
   text colour is by construction the one colour already proven against that
   fill, so the border inherits a ratio between 5.3:1 and 7.9:1 without anyone
   having to keep a table of them in step. Each status now differs from its
   neighbours in outline as well as fill.

   It is bigger than it was, and that is the release rather than an accident:
   measured, 未结 went from 42.9 x 25.3 to 53.0 x 34.3 — the word inside it went
   from 11.84px to 15.75px, and the border added a pixel a side. A status that
   has to be read across a counter is worth the eleven pixels. */
.pill {
  display: inline-block; font-size: 0.875rem; padding: 2px 9px;
  border: 1px solid currentColor;
  border-radius: 999px; font-weight: 500; white-space: nowrap;
  background: var(--soft); color: var(--ink-2);
}
.pill--on   { background: var(--accent-2);   color: var(--accent); }
.pill--warn { background: var(--amber-2); color: var(--amber); }
.pill--err  { background: var(--red-2);  color: var(--red); }
.pill--off  { background: var(--soft);   color: var(--muted); }

/* --- A row of actions -----------------------------------------------------
   Every direct child is one control, and a <form> wrapping a single button is
   still one control — so the form has to be a flex row too, or its contents
   stack. That is what put 取消 on a line of its own underneath a full-width
   reason box, with 确认 beside a text field three times its height: three
   controls, three different sizes, on two rows, for what is one row of choices.

   The input carries the button's height and a sensible width rather than
   eating the row. It is a reason, not an essay.
   ------------------------------------------------------------------------- */
.actionbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.actionbar form {
  margin: 0;
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.actionbar input[type="text"] {
  height: 44px; padding: 0 12px;
  width: auto; min-width: 180px; max-width: 260px; flex: 0 1 220px;
}
/* Same height as the inputs and each other, whatever weight they wear. */
.actionbar .btn { min-height: 44px; }

@media (max-width: 480px) {
  /* One control per line beats three cramped ones, and the reason box may as
     well have the width once it has the row. */
  .actionbar form { width: 100%; }
  .actionbar input[type="text"] { flex: 1 1 auto; max-width: none; }
}

/* --- Booking terms ------------------------------------------------------- */
.terms {
  background: var(--soft);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 14px 16px;
  margin-top: 18px;
}
.terms__title { font-family: var(--serif); font-weight: 600; margin-bottom: 6px; }
.terms__list { margin: 0; padding-left: 1.2em; font-size: .88rem; color: var(--ink-2); }
.terms__list li { margin-bottom: 4px; }
.terms__list li:last-child { margin-bottom: 0; }

button[disabled] { opacity: .45; cursor: not-allowed; }

/* --- Receipt ------------------------------------------------------------- */
.receipt { max-width: 420px; margin: 0 auto; }
.receipt__head { text-align: center; }
.receipt__brand { font-family: var(--brand); font-size: 1.2rem; font-weight: 600; }
.receipt__legal { font-size: 0.875rem; letter-spacing: .04em; margin-top: 2px; }
.receipt__reg, .receipt__addr { font-size: 0.875rem; color: var(--muted); line-height: 1.5; }
.receipt__addr { margin-top: 6px; }
.receipt__rule { border: 0; border-top: 1px dashed var(--line); margin: 14px 0; }
.receipt__meta { display: flex; justify-content: space-between; gap: 12px; padding: 1px 0; }
.receipt__lines { width: 100%; border-collapse: collapse; font-size: .9rem; }
.receipt__lines td { border: 0; padding: 3px 0; vertical-align: top; }
.receipt__total td { font-weight: 600; font-size: 1.05rem; padding-top: 8px; border-top: 1px solid var(--line); }
.receipt__thanks { text-align: center; font-family: var(--serif); margin: 18px 0 4px; }

@media print {
  body { background: #fff; }
  .site-header, .site-footer, .btn, .adminnav { display: none !important; }
  .card { border: 0; box-shadow: none; padding: 0; }
}

/* --- Message outbox ------------------------------------------------------ */
.msg { border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 14px; margin-bottom: 12px; }
/* The age and the two pills. They fold rather than pushing the page sideways;
   each pill keeps its own `white-space: nowrap`, so nothing breaks mid-word. */
.msg__when { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; justify-content: flex-end; }
.msg__head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 8px; flex-wrap: wrap; }
.msg__body {
  white-space: pre-wrap; word-break: break-word;
  background: var(--soft); border-radius: var(--radius-sm);
  padding: 10px 12px; margin: 0 0 12px;
  font-family: var(--sans); font-size: .88rem; line-height: 1.6;
}
.chip {
  display: inline-block; background: var(--soft); border: 1px solid var(--line-2);
  border-radius: 4px; padding: 1px 6px; font-family: var(--mono); font-size: 0.875rem; margin-right: 4px;
}

/* --- Events and tickets --------------------------------------------------- */
.eventcard { display: block; text-decoration: none; color: inherit; }
.eventcard:hover { border-color: var(--accent); color: inherit; }

/* With a photo the card loses its padding and gains a picture across the top;
   the text moves inside its own box so the image can reach the edges. On a wide
   screen the picture goes to the left instead, because a 16:9 band across a
   1200px card is a poster, not a list item. */
.eventcard--photo { padding: 0; overflow: hidden; }
/* An event card changes job with the screen, so its photograph changes shape
   with it. On a phone the card is full width and the picture is the hero — 3:2.
   On a desktop it sits at 240px beside the text, where it is a thumbnail, and a
   square balances the block of type next to it. Same rule as everywhere else:
   grid thumbnails square, full-width photographs landscape. */
.eventcard__photo { background: var(--soft); }
/* 4:5, the shape the poster was designed at, and the same one the event page
   uses. An Instagram post drops in with nothing trimmed at all; the words on a
   poster are part of the picture, so this is the one place where matching the
   source shape matters more than matching the rest of the site. */
.eventcard__photo img {
  display: block; width: 100%; aspect-ratio: calc(4 / 5);
  object-fit: cover; object-position: center;
  background: var(--soft);
}
.eventcard--photo .eventcard__body { padding: 16px 18px 18px; }

@media (min-width: 720px) {
  .eventcard--photo { display: flex; align-items: stretch; }
  /* Beside the text on a wide screen, where it is a thumbnail again. Same 4:5,
     narrower: 200px wide is 250px tall, which sits against the title, date and
     price without making one event fill a page meant for browsing. */
  .eventcard__photo { flex: 0 0 200px; }
  .eventcard__photo img { width: 200px; height: auto; }
  .eventcard--photo .eventcard__body {
    flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center;
  }
}
.eventcard__when { font-size: 0.875rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.eventcard__title { font-family: var(--serif); font-size: 1.15rem; font-weight: 600; margin: 2px 0 8px; }
.eventcard__meta { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; font-size: .88rem; }

.ticket { text-align: center; padding: 18px 0; border-top: 1px dashed var(--line); }
.ticket:first-of-type { border-top: 0; }
.ticket__qr { width: 190px; margin: 0 auto 8px; }
.ticket__qr svg { width: 100%; height: auto; display: block; border-radius: 6px; }
.ticket__code { font-size: 1.05rem; letter-spacing: .12em; font-weight: 600; }

/* --- Tea shop ------------------------------------------------------------- */
.shopgrid { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; }
@media (min-width: 720px) { .shopgrid { grid-template-columns: repeat(3, 1fr); } }

.prodcard { display: block; text-decoration: none; color: inherit; }
.prodcard:hover { border-color: var(--accent); color: inherit; }
.prodcard__img {
  width: 100%; aspect-ratio: calc(1 / 1); object-fit: cover;
  border-radius: var(--radius-sm); margin-bottom: 10px; display: block;
  background: var(--soft);
}
.prodcard__name { font-family: var(--serif); font-size: 1.08rem; font-weight: 600; margin-bottom: 4px; }
.prodcard__meta { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; font-size: .93rem; }

.table-plain td { padding: 8px 0; }
.table-plain tbody tr:last-child td { border-bottom: 0; }

.choice { display: flex; align-items: center; gap: 9px; margin-bottom: 8px; font-weight: 400; }
.choice input { flex: 0 0 auto; width: auto; }

fieldset { border: 0; padding: 0; margin: 0 0 16px; }
fieldset legend { font-size: .88rem; font-weight: 500; margin-bottom: 7px; color: var(--ink-2); padding: 0; }

.stack > * + * { margin-top: 12px; }

/* --- CRM segments --------------------------------------------------------- */
.seggrid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
.segcard {
  display: block; text-decoration: none; color: inherit;
  border: 1px solid var(--line-2); border-radius: var(--radius-sm);
  padding: 12px 14px; background: var(--app);
}
.segcard:hover { border-color: var(--accent); color: inherit; }
.segcard__n { font-family: var(--serif); font-size: 1.6rem; font-weight: 600; line-height: 1.1; }
.segcard__label { font-size: 0.875rem; color: var(--muted); margin-top: 2px; }

/* --- Money ----------------------------------------------------------------
   Amber, and only here. A price, a total, an outstanding balance. The rule the
   prototype set was that money is the one thing on the page allowed to be a
   different colour, and it only works while nothing else borrows it.
   ------------------------------------------------------------------------- */
.money, td.num.money, .stat__value.money { color: var(--amber); font-variant-numeric: tabular-nums; }
.money--muted { color: var(--muted); }

/* Figures line up in columns without needing a monospace face everywhere. */
.mono, td.num, th.num, .stat__value { font-variant-numeric: tabular-nums; }

/* --- Bottom tab bar -------------------------------------------------------
   From the prototype. Fixed on phones, where a thumb reaches the bottom of
   the screen far more easily than the top; on wide screens the header is
   already visible and a floating bar is just clutter, so it goes away.
   ------------------------------------------------------------------------- */
.tabbar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
  display: flex;
  background: var(--card);
  border-top: 1px solid var(--line);
  padding-bottom: env(safe-area-inset-bottom, 0);
  box-shadow: 0 -2px 12px rgba(27,33,29,.06);
}
.tabbar__item {
  position: relative;
  flex: 1 1 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 2px;
  padding: 8px 4px 7px;
  min-height: 56px;
  text-decoration: none;
  color: var(--muted);
  font-size: 0.875rem;
  line-height: 1.2;
}
.tabbar__item:hover { color: var(--ink-2); }
.tabbar__item[aria-current="page"] { color: var(--accent-hi); font-weight: 500; }
.tabbar__icon { font-size: 1.15rem; line-height: 1; }
/* Four columns rather than five is what buys the customer's labels their full
   names: ~90px each on a 360px phone instead of ~72px, which is the difference
   between 空间租借 and an abbreviation nobody asked for.

   The customer's bar used to be set larger than the back office's, because the
   back office has six sections across the same width and the English words did
   not fit at that size. Both are at the 14px floor now — the floor is the whole
   point of 058 — and the back office's six labels fit because it has its own
   shorter words for this bar and nowhere else: `nav.tab_*` in the language
   files, and the note there says why. */
.tabbar__label { white-space: nowrap; }

/* The bar sits over the page, so the last thing on the page needs somewhere to
   go — and the reserve belongs on the page, not on the footer.
   ---------------------------------------------------------------------------
   It used to be `.site-footer { padding-bottom: 76px }`, which works only for
   pages that end in a footer the customer has scrolled all the way to. The
   booking calendar does not: it is the last block on a short page, its final
   row of dates sat under the bar, and on a month whose last days are the only
   bookable ones there was nothing to tap. A phone gives no hint that anything
   is hidden — the page simply stops.

   On the body, every page reserves the space, whatever it ends with. The safe
   area is added on top for the phones with a home indicator. */
body:has(.tabbar) { padding-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); }

@media (min-width: 760px) {
  .tabbar { display: none; }
  body:has(.tabbar) { padding-bottom: 0; }
}

/* --- The header row on a wide screen --------------------------------------
   The same four doors as the bottom bar, from the same list. The bar is hidden
   above 760px and this appears at exactly that width, so the site is navigable
   at every size and the two are never both on screen. Before this, a laptop
   had no cross-navigation at all: the logo, and then back to the tiles.
   ------------------------------------------------------------------------- */
.topnav { display: none; }

@media (min-width: 760px) {
  .topnav {
    display: flex; align-items: center; gap: 22px;
    margin-left: auto; margin-right: 4px;
  }
  .topnav a {
    color: var(--ink-2); text-decoration: none;
    font-size: .92rem; letter-spacing: var(--track-cjk);
    padding: 4px 0; border-bottom: 2px solid transparent;
  }
  .topnav a:hover { color: var(--ink); }
  .topnav a[aria-current="page"] {
    color: var(--accent-hi); border-bottom-color: var(--accent); font-weight: 500;
  }
}

/* --- The basket, in the header --------------------------------------------
   It used to be a fifth column in the bottom bar, which meant it vanished
   above 760px along with the bar — a shop whose basket is invisible on a
   laptop. Up here it is visible at every width, and it costs the bottom bar
   nothing.

   The count is a badge rather than a number beside the bag because the number
   is the only part that changes, and a badge is the shape a person's eye
   already knows to check.
   ------------------------------------------------------------------------- */
.cartlink {
  position: relative;
  display: inline-grid; place-items: center;
  width: 44px; height: 44px;
  border-radius: 999px;
  color: var(--ink-2);
  text-decoration: none;
}
.cartlink svg { width: 21px; height: 21px; display: block; }
.cartlink:hover { background: var(--soft); color: var(--ink); }
.cartlink[aria-current="page"] { color: var(--accent-hi); }
.cartlink__count {
  position: absolute; top: 1px; right: 0;
  min-width: 17px; height: 17px; padding: 0 4px;
  border-radius: 999px;
  background: var(--amber); color: #fff;
  font-family: var(--mono); font-size: 0.875rem; font-weight: 500;
  letter-spacing: 0;
  display: grid; place-items: center;
}

/* --- The language switch --------------------------------------------------
   Both languages, always, with the current one filled in.

   The old control was a single link reading "EN". That asks the reader to
   work out whether it is telling them what they are in or what they would get
   — the one question a language switch must not pose, and the reader least
   able to answer it is exactly the one who needs the control.

   Each option is labelled in its own language: 中文 is written 中文 whether or
   not the page around it is English. Renaming the option somebody is hunting
   for into a language they cannot read defeats the entire control.
   ------------------------------------------------------------------------- */
.langpill {
  display: inline-flex; align-items: center;
  padding: 2px;
  border: 1px solid var(--line-2);
  border-radius: 999px;
  background: var(--soft);
}
/* Two of these are links — the language switch — and two are submit buttons:
   白天 / 晚上 writes to the person's row, so it cannot be a GET. They have to
   be indistinguishable, so the button's own chrome comes off here rather than
   in a second rule that could later fall out of step with this one. */
.langpill__opt {
  appearance: none; border: 0; background: transparent;
  font: inherit; cursor: pointer;
  display: block;
  padding: 4px 11px;
  border-radius: 999px;
  font-size: 0.875rem; line-height: 1.4;
  letter-spacing: var(--track);
  color: var(--muted); text-decoration: none;
  white-space: nowrap;
  transition: background .12s ease, color .12s ease;
}
.langpill__opt:hover { color: var(--ink); }
.langpill__opt.is-on {
  background: var(--card);
  color: var(--ink);
  font-weight: 500;
  box-shadow: 0 1px 2px rgba(23,32,27,.10);
}
@media (prefers-reduced-motion: reduce) {
  .langpill__opt { transition: none; }
}

/* --- Up ------------------------------------------------------------------
   Not the browser's Back. This link is worked out from the address, so it
   always leads somewhere and always leads to the same place — and it says
   which place, so following it is never a gamble.

   It sits top-left, where a phone has put this control since 2007, and it is
   the first thing inside <main> so a keyboard reaches it before the page.
   ------------------------------------------------------------------------- */
.uplink {
  display: inline-flex; align-items: center; gap: 3px;
  margin: 16px 0 -4px;
  padding: 5px 12px 5px 8px;
  border: 1px solid var(--line-2);
  border-radius: 999px;
  background: var(--card);
  color: var(--ink-2); text-decoration: none;
  font-size: 0.875rem; letter-spacing: var(--track-cjk);
}
.uplink:hover { color: var(--ink); border-color: var(--line-2); }
.uplink__arrow { font-size: 1.15rem; line-height: 1; margin-top: -2px; }

/* ---------------------------------------------------------------------------
   Prototype components
   --------------------------------------------------------------------------- */

/* --- Hero ----------------------------------------------------------------
   The prototype opens with a soft green light behind the brand and a leaf
   drawn very faintly into the top-right corner. Both are decoration: they sit
   in ::before/::after so no markup carries them and nothing here is read out
   to a screen reader.
   ------------------------------------------------------------------------- */
.hero { position: relative; overflow: hidden; }
.hero::before {
  content: ""; position: absolute; inset: -40% 0 auto 0; height: 420px;
  background: radial-gradient(60% 70% at 50% 45%, rgba(111,214,158,.10), transparent 70%);
  pointer-events: none;
}
.hero::after {
  content: ""; position: absolute; top: -56px; right: -70px;
  width: 240px; height: 240px; pointer-events: none; opacity: .07;
  background: no-repeat center/contain url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' fill='none' stroke='%236FD69E' stroke-width='1.1'%3E%3Cpath d='M50 6c22 14 30 34 30 50 0 22-13 38-30 38S20 78 20 56C20 40 28 20 50 6Z'/%3E%3Cpath d='M50 12v80M50 40 30 28M50 40l20-12M50 62 28 48M50 62l22-14'/%3E%3C/svg%3E");
}
:root[data-theme="light"] .hero::before, :root[data-theme="light"] .hero::after { opacity: .07; }
.hero > * { position: relative; }

/* --- Notice --------------------------------------------------------------
   The amber box the prototype uses to explain the overlap: two sessions share
   the room, so booking one takes seats off the other. It is the single least
   obvious thing about the whole booking model, and it earns the interruption.
   ------------------------------------------------------------------------- */
.notice {
  background: var(--amber-2);
  border: 1px solid color-mix(in srgb, var(--amber) 32%, transparent);
  color: var(--amber);
  border-radius: var(--radius-sm);
  padding: 12px 14px;
  font-size: .9rem; line-height: 1.55;
  margin-bottom: 14px;
}

/* --- Session cards -------------------------------------------------------- */
.sessioncard { flex-wrap: wrap; align-items: center; padding: 12px 16px; }
/* One line, not two: the duration sits beside the time rather than under it,
   which takes a whole row of height out of every card in the list. */
.sessioncard__main { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; }
.sessioncard__mins { font-size: 0.875rem; color: var(--muted); }
.sessioncard__bar {
  flex: 0 0 100%; height: 3px; border-radius: 999px;
  background: var(--soft); overflow: hidden; margin-top: 10px;
}
.sessioncard__bar > span { display: block; height: 100%; background: var(--accent); }
.sessioncard--out .sessioncard__bar > span { background: var(--muted); }

/* --- Stat tiles ----------------------------------------------------------- */
.stat {
  border: 1px solid var(--line); border-radius: var(--radius);
  padding: 14px 16px; background: var(--card);
}
.stat__value { font-family: var(--mono); font-size: 1.7rem; font-weight: 500; letter-spacing: -.01em; }
.stat__label { font-size: 0.875rem; }

/* --- Product tiles -------------------------------------------------------
   Photography is Gan's to supply. Until a tea has one, the card shows a tinted
   panel rather than a grey box or a broken-image icon — the prototype's own
   answer, and it keeps a half-stocked shop looking deliberate.
   ------------------------------------------------------------------------- */
.prodcard { padding: 0; overflow: hidden; }
/* Square — the same crop as the gallery it leads to.
   That match is the point and it outlives the number: a customer taps a picture
   in the grid and the next screen shows *that picture*, not one that has grown
   or lost its edges on the way.
   Square rather than portrait because of what the photographs actually are.
   Portrait 2:3 was tried on the reasoning that teas are shot upright; the
   pictures that arrive are packaging head-on — a box beside a bowl of leaf,
   about as wide as it is tall — and 2:3 cut the bowl off the side. Square keeps
   the arrangement whole, and it tiles, which is what a grid is for. */
.prodcard__img, .prodcard__ph {
  width: 100%; aspect-ratio: calc(1 / 1); object-fit: cover; display: block; margin: 0;
  border-radius: 0;
}
.prodcard__ph {
  --ph-h: 150;
  background: linear-gradient(150deg,
    hsl(var(--ph-h) 26% 46%), hsl(calc(var(--ph-h) + 18) 24% 32%));
  display: grid; place-items: center; color: #fff;
}
.prodcard__ph svg { width: 44%; height: 44%; opacity: .34; }
.prodcard__body { display: block; padding: 11px 13px 13px; }
.prodcard__name { margin-bottom: 2px; font-size: 1rem; line-height: 1.35; }
.prodcard__unit { font-size: 0.875rem; color: var(--muted); }
.prodcard__meta { margin-top: 5px; gap: 6px; font-size: .92rem; }
.prodcard__flag {
  position: absolute; top: 10px; left: 10px;
  background: var(--amber); color: var(--on-accent);
  font-size: 0.875rem; font-weight: 600; padding: 2px 9px; border-radius: 999px;
}
.prodcard { position: relative; }
.prodcard__out {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: rgba(10,15,13,.62); color: var(--ink);
  font-family: var(--serif); font-size: 1.05rem; letter-spacing: .1em;
}
:root[data-theme="light"] .prodcard__out { background: rgba(255,255,255,.72); }

/* --- Step progress -------------------------------------------------------- */
.steps { display: flex; align-items: center; gap: 8px; margin-bottom: 18px; }
.steps__track { display: flex; gap: 6px; flex: 1 1 auto; }
/* Done against not-yet, and the pair has to separate from each other as well
   as from the page. When `--line` went from 1.28:1 to 2.2:1 for the sake of
   card edges it moved *towards* `--accent`, and this bar is the one place the
   two are read against each other: 4.88:1 -> 2.81:1. `--accent-hi` is the
   darker green in the light theme and the brighter one in the dark, so it
   pulls away from the track in both directions — 3.81:1 and 4.24:1. */
.steps__track span { flex: 1 1 0; height: 3px; border-radius: 999px; background: var(--line); }
.steps__track span.on { background: var(--accent-hi); }
.steps__label { font-size: 0.875rem; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }

/* --- Home: the prototype's composition ------------------------------------ */
.hero { padding: 52px 0 26px; text-align: center; }
.hero__name {
  font-family: var(--serif); font-size: 2.3rem; font-weight: 600;
  margin: 0 0 6px; letter-spacing: .02em;
}
.hero__caps {
  margin: 0; color: var(--muted);
  font-family: var(--mono); font-size: 0.875rem;
  letter-spacing: .22em; text-transform: uppercase;
}

.statusbar {
  display: flex; align-items: flex-start; gap: 10px;
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 13px 16px; margin-bottom: 10px;
  font-size: .88rem; line-height: 1.5;
}
.statusbar__dot {
  flex: 0 0 7px; width: 7px; height: 7px; border-radius: 999px;
  background: var(--accent); margin-top: 8px;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
.statusbar__dot--off { background: var(--muted); box-shadow: none; }

.nextcard {
  position: relative; display: block; text-decoration: none; color: var(--ink);
  border: 1px solid var(--accent); border-radius: var(--radius);
  background: color-mix(in srgb, var(--accent) 7%, transparent);
  padding: 14px 40px 14px 16px; margin-bottom: 22px;
}
.nextcard:hover { color: var(--ink); background: color-mix(in srgb, var(--accent) 12%, transparent); }
.nextcard__label { display: block; font-size: 0.875rem; color: var(--accent); letter-spacing: .04em; }
.nextcard__when  { display: block; font-size: 1.12rem; font-weight: 600; margin: 2px 0 1px; }
.nextcard__meta  { display: block; font-size: 0.875rem; color: var(--muted); }
.nextcard__go, .door__go {
  position: absolute; right: 16px; top: 50%; transform: translateY(-50%);
  color: var(--muted); font-size: 1.3rem; line-height: 1;
}

.door {
  position: relative; display: flex; align-items: center; gap: 14px;
  text-decoration: none; color: var(--ink);
  background: var(--card); border: 1px solid var(--line-2);
  border-radius: var(--radius); padding: 14px 40px 14px 14px; margin-bottom: 10px;
}
.door:hover { border-color: var(--accent); color: var(--ink); }
.door__icon {
  flex: 0 0 46px; height: 46px; border-radius: 12px;
  background: var(--accent-2); color: var(--accent);
  display: grid; place-items: center;
}
.door__icon svg { width: 22px; height: 22px; }
.door__text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.door__title { font-family: var(--serif); font-size: 1.12rem; font-weight: 600; }
.door__sub { font-size: 0.875rem; color: var(--muted); }

.hours { margin: 22px 0 8px; }
.hours summary {
  cursor: pointer; color: var(--muted); font-size: .88rem;
  padding: 8px 0; list-style: none;
}
.hours summary::-webkit-details-marker { display: none; }
.hours summary::before { content: "▸ "; }
.hours[open] summary::before { content: "▾ "; }
.hours summary:hover { color: var(--ink); }

.tabbar__icon svg { width: 21px; height: 21px; display: block; }

/* --- Photo gallery editor -------------------------------------------------
   Four slots, drawn as slots. How many are used and how many remain is the
   first thing you should be able to see, which a list of thumbnails does not
   tell you.
   ------------------------------------------------------------------------- */
/* How many photographs across.
   ---------------------------------------------------------------------------
   Driven by the controls, not by the pictures. Each slot carries ‹ › and × in
   its bottom corner, and at the 44px floor those need 136px across; the slot
   clips whatever does not fit, and clipping takes the tap with it — measured,
   the × that deletes a photograph was 30% hittable on a 320px screen.

   So: one column below 480px (a slot 256px wide), two up to 759, four above.
   Four across only starts at 760 because at 560 four columns are 115px, which
   is back under the controls again. Bigger pictures on a phone is also simply
   the better read — this is the screen where you decide whether a crop is
   right. */
.gallery { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 12px; }
@media (max-width: 480px) { .gallery { grid-template-columns: 1fr; } }
@media (min-width: 760px) { .gallery { grid-template-columns: repeat(4, 1fr); } }

/* Each slot is the shape that module actually publishes, so what you see while
   uploading is the crop the customer gets. Square slots looked tidier and hid
   exactly the thing worth checking. */

.gallery__slot {
  position: relative; min-width: 0;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--soft);
}
/* `cover`, deliberately — this is a preview of the published crop, not of the
   file. Seeing the whole photograph here would hide the one thing worth
   checking, which is what the crop is about to remove. The 会裁切 mark says when
   that is a lot; the file itself is always still whole underneath. */
.gallery__slot img { width: 100%; height: auto; object-fit: cover; display: block;
                     border-radius: var(--radius-sm) var(--radius-sm) 0 0; }

/* Quiet, and amber rather than red: it is a thing to look at, not a thing that
   has gone wrong. It used to sit opposite the cover badge, on the same line,
   which worked while both were 11.34px: at the 14px floor 会裁切 is 63px and
   封面 is 47, and on a 87.6px tile in the five-across space gallery they
   overlapped by 37px — so the cover mark, on the cover photograph, was the one
   you could not read. Second row instead, still the same corner. */
.gallery__crop {
  position: absolute; top: 40px; right: 6px;
  background: var(--amber); color: var(--on-accent);
  font-size: 0.875rem; font-weight: 600; padding: 1px 7px; border-radius: 999px;
}

.gallery__cover {
  position: absolute; top: 6px; left: 6px;
  background: var(--accent); color: var(--on-accent);
  font-size: 0.875rem; font-weight: 600; padding: 1px 7px; border-radius: 999px;
}
/* **The controls sit under the photograph, not over it.**
   ---------------------------------------------------------------------------
   They were an overlay in a gradient across the bottom of the picture, which
   was handsome and worked while ‹, › and × were about 18px each. At the 44px
   floor they are 136px across, and a slot is 77px wide on a 320px screen and
   88 in the five-across space gallery on a tablet. The slot clips what does
   not fit — and `overflow: hidden` clips the *tap* along with the pixels:
   measured, the × that deletes a photograph was **30% hittable at 320px**,
   60% at 390 and 82% at 760.

   Chasing that with breakpoints meant a different answer at each of five
   gallery shapes and three column counts, and one of them was always wrong.
   So the row comes out from under the picture at every width: it can wrap, it
   is never over the thing it acts on, and there is nothing left to clip. The
   ratio moves from the slot to the image so the slot can grow by the height of
   that row; `min-width: 0` because a grid item with visible overflow otherwise
   refuses to shrink below its content and takes the whole column with it. */
/* Each picture is the shape that module actually publishes, so what you see
   while uploading is the crop the customer gets. Square slots looked tidier
   and hid exactly the thing worth checking. The ratio is on the image rather
   than on the slot because the slot now has a row of controls under it and has
   to be able to grow by their height — see the note below. */
.gallery__slot img { aspect-ratio: calc(3 / 2); height: auto; }
.gallery--product .gallery__slot img,
.gallery--variant .gallery__slot img { aspect-ratio: calc(1 / 1); }
.gallery--event   .gallery__slot img { aspect-ratio: calc(4 / 5); }
.gallery--room    .gallery__slot img,
.gallery--space   .gallery__slot img { aspect-ratio: calc(3 / 2); }
.gallery__tools {
  display: flex; justify-content: center; flex-wrap: wrap; gap: 2px;
  padding: 4px 0 0;
}
.gallery__tools form { margin: 0; }
.gallery__tools .btn--link { color: var(--ink-2); font-size: 1rem; padding: 2px 8px; }
.gallery__tools .btn--link:hover { color: var(--accent); }

/* The one slot that is a form, not a photograph.
   ---------------------------------------------------------------------------
   It inherited `aspect-ratio: 3/2` and `overflow: hidden` from `.gallery__slot`,
   which is right for a picture and wrong for three stacked controls: at 390px
   that box is 65px tall inside, and a label, a file field and 上传 at the 44px
   floor need 172. The button was clipped to under three visible pixels — on
   the only route to adding a photograph anywhere in the back office.

   So this one sizes itself. `align-self: start` keeps it from stretching the
   photographs beside it, which do keep their ratio. */
.gallery .gallery__slot--add {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 8px; padding: 12px; text-align: center;
  border-style: dashed; background: transparent;
  /* Its own row, under the photographs.
     `.gallery--room .gallery__slot` is two classes and outranks one, so
     `aspect-ratio: auto` has to be written at the same weight or the 3:2 ratio
     comes back and multiplies the 172px height into a 258px width — which then
     hangs 146px off the side of a 320px screen. And a form of three stacked
     44px controls does not belong in a 77px column beside the pictures: it
     takes the width of the row instead, which is also the clearer reading —
     the photographs, and then the box for adding one. */
  grid-column: 1 / -1;
  /* `overflow: hidden` stays. It was `visible` for one round, and that turned
     the grid item's automatic minimum size back on — the file input inside is
     `width: 100%`, so the add slot claimed its min-content and starved the
     photographs beside it down to 2px columns on a phone. Nothing is clipped
     by keeping it hidden now that the box sizes itself: the ratio is off and
     the height is the height its three controls need. */
  aspect-ratio: auto; min-height: 172px; min-width: 0; align-self: start;
}
.gallery__slot--add label { font-size: 0.875rem; color: var(--muted); margin: 0;
  min-height: 44px; display: inline-flex; align-items: center; }
.gallery__slot--add input[type="file"] {
  width: 100%; min-height: 44px; font-size: 0.875rem; padding: 0;
  background: none; border: 0; color: var(--muted);
}
.gallery__slot--empty { border-style: dashed; opacity: .45; min-height: 96px; }

/* Before the first save: the slots are visible so the feature is obviously
   there, and inert so it is obvious it is not yet usable. */
.gallery__slot--locked {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 4px; text-align: center; padding: 10px;
  border-style: dashed; background: transparent; opacity: .5;
}
.gallery__lockmark { font-size: 1.5rem; color: var(--muted); line-height: 1; }
.card--waiting .card__title { color: var(--muted); }

/* --- Public event gallery -------------------------------------------------- */
/* .prod__img and .evgallery lived here — a tea's photographs stacked down the
   page and an event's cropped into a two-up grid. Both are gone: every gallery
   on the site is now views/partials/slides.php. Dead CSS is worse than dead
   code, because nothing errors when you leave it in. */

/* --- Booking progress ------------------------------------------------------
   Four steps, every bar filled up to and including the current one. A customer
   who cannot see how much is left assumes it is more than it is.
   ------------------------------------------------------------------------- */
.prog { display: flex; align-items: center; gap: 9px; margin: 0 0 18px; }
.prog .bars { display: flex; gap: 4px; flex: 1; }
.prog .bars i {
  height: 3px; flex: 1; border-radius: 2px; background: var(--line); display: block;
}
/* Same pair, same reason — see .steps__track above. */
.prog .bars i.done { background: var(--accent-hi); }
.prog .lab {
  font-family: var(--mono); font-size: 0.875rem; letter-spacing: .06em;
  color: var(--muted); white-space: nowrap;
}
.prog .lab b { color: var(--accent); font-weight: 500; }

/* --- Month calendar -------------------------------------------------------- */
.calhead { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.calhead b { font-family: var(--serif); font-size: 1.06rem; font-weight: 600; }
.calnav {
  display: grid; place-items: center;
  width: 44px; height: 44px; border-radius: 50%;
  border: 1px solid var(--line-2); color: var(--muted);
  text-decoration: none; font-size: .9rem; line-height: 1;
}
.calnav:hover { border-color: var(--accent); color: var(--accent); }
.calnav--off { opacity: .3; }

.dow { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 6px; }
.dow span { text-align: center; font-size: 0.875rem; color: var(--muted); font-family: var(--mono); }

.calgrid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.day {
  aspect-ratio: 1; border: 1px solid transparent; border-radius: 9px;
  background: transparent; font-size: .88rem; color: var(--ink);
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
  text-decoration: none; font-variant-numeric: tabular-nums;
}
.day--pad { border: 0; }

/* Today, on the customer's calendar. The same circle as the two admin
   calendars — a guest scanning a month for a Saturday still has to find where
   they are before they can count forwards. */
.day.today > span:first-child {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 22px; height: 22px; border-radius: 50%;
  border: 1.5px solid var(--accent); color: var(--accent); font-weight: 600;
}
.day.today.closed > span:first-child { border-color: var(--line-2); color: inherit; }
/* 休息日 is something a customer actively scans for, not a greyed-out control.
   At --line-2 it measured 1.66:1 against the card — invisible on 26 of 31
   cells. Quieter than --ink, and readable: 4.52:1. */
.day.closed { color: #7A8C81; }
/* A day the room is open on, on the customer's calendar. The border is the
   only thing saying so — the fill is a card on a card — which makes it a state
   indicator and not a container edge. */
.day.open { background: var(--card); border-color: var(--line-2); }
a.day.open:hover { border-color: var(--accent); color: var(--ink); }
.day.full { background: var(--soft); color: var(--muted); }

/* The bar is free capacity across the whole day — every seat in every session,
   not the best single slot. Someone scanning a month wants to know whether a
   Saturday is filling, and the best slot on a nearly-full day says the opposite. */
.day .bar {
  width: 60%; height: 3px; border-radius: 2px;
  background: var(--line-strong); overflow: hidden;
}
.day .bar > span { display: block; height: 100%; background: var(--accent); border-radius: 2px; }
.day.low .bar > span { background: var(--amber); }
.day.full .bar > span { background: var(--line-2); }

.callegend {
  display: flex; gap: 16px; flex-wrap: wrap;
  font-size: 0.875rem; color: var(--muted); margin-top: 14px;
}
.callegend i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 5px; }

/* --- Stepper --------------------------------------------------------------
   Quantity. The ends disable rather than letting the value run past what the
   session can actually take — a control that refuses is kinder than a form
   that accepts and then rejects.
   ------------------------------------------------------------------------- */
.stepper {
  display: flex; align-items: center;
  border: 1px solid var(--line-2); border-radius: var(--radius-sm);
  overflow: hidden; width: fit-content; background: var(--card);
}
.stepper button {
  appearance: none; border: 0; background: var(--card); color: var(--ink);
  width: 46px; height: 48px; font-size: 1.2rem; cursor: pointer; font-family: inherit;
}
.stepper button:hover:not(:disabled) { background: var(--soft); color: var(--accent); }
.stepper button:disabled { color: var(--line-2); cursor: not-allowed; }
.stepper .v {
  width: 62px; height: 48px; text-align: center;
  font-family: var(--mono); font-size: 1.05rem; font-weight: 500;
  border: 0; border-left: 1px solid var(--line-2); border-right: 1px solid var(--line-2);
  border-radius: 0; background: var(--card); color: var(--ink);
  font-variant-numeric: tabular-nums; padding: 0;
  -moz-appearance: textfield;
}
.stepper .v::-webkit-outer-spin-button,
.stepper .v::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.stepper .v:focus { outline: 2px solid var(--accent); outline-offset: -2px; }

/* Four or fewer seats reads amber: scarcity is the same signal as money here,
   and it is the number that decides whether someone books now or later. */
.sessioncard--low .sessioncard__seats { color: var(--amber); }
.sessioncard--low .sessioncard__bar > span { background: var(--amber); }

/* ---------------------------------------------------------------------------
   Choosing a sitting, on the same page as everything else

   The sittings used to be links to a third screen. They are radios now, inside
   the one form — so changing your mind about the time keeps everything you have
   already typed, which is most of what made the old walk feel long.

   The radio itself is not hidden with display:none: a hidden input cannot be
   reached by a keyboard, and this is the control the whole page turns on. It is
   made invisible and left in the layout, and the label carries the focus ring
   on its behalf.
   ------------------------------------------------------------------------- */
.sessionpick { border: 0; margin: 0; padding: 0; }
.sessionpick legend { padding: 0 0 8px; }

label.sessioncard { cursor: pointer; position: relative; }
label.sessioncard:hover { border-color: var(--accent); }

.sessioncard > input[type="radio"] {
  position: absolute;
  width: 1px; height: 1px;
  opacity: 0;
  margin: 0;
  pointer-events: none;
}

/* ── Choosing a sitting ─────────────────────────────────────────────────────
   Gan: 「i selected but it does not visual impact .. i need to see again to
   confirm i select the right slot」.

   Two things were wrong, and the first is the one that matters.

   **`--on` is written by PHP from the *submitted* value.** It cannot change
   when you click, because nothing has been submitted yet — so tapping a
   different sitting changed the page not at all until it reloaded. The state
   was in the radio the whole time and the styling was not listening to it.
   `:has(input:checked)` listens to it, and answers on the tap.

   **And the loudest thing on every card was identical.** All three carried a
   full-width accent capacity bar, so the eye went to three bright green lines
   and the one-pixel border that marked the choice lost the competition.

   So: the radio is *shown* rather than hidden — a real ring that fills in, which
   is the affordance everybody already knows — the chosen card is lifted off the
   ground with its own surface and a two-pixel edge, its time is set in full-
   strength ink, and the unchosen bars are quietened so they inform without
   shouting. `--on` is kept alongside as the server-rendered fallback, so the
   page still shows its state with no CSS `:has` and no JavaScript at all. */

.sessioncard::before {
  content: "";
  flex: 0 0 auto;
  width: 18px; height: 18px;
  border-radius: 50%;
  border: 2px solid var(--line-2);
  align-self: center;
}
.sessioncard--out::before { opacity: .4; }

label.sessioncard:has(input:checked)::before,
.sessioncard--on::before {
  border-color: var(--accent);
  /* The dot, drawn rather than typed: a glyph would depend on a face that may
     not carry it, and this one has to be right on every device. */
  background: radial-gradient(circle at 50% 50%, var(--accent) 0 4px, transparent 4.5px);
}

label.sessioncard:has(input:checked),
.sessioncard--on {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 2px var(--accent);
  background: var(--soft);
}

label.sessioncard:has(input:checked) .sessioncard__time,
.sessioncard--on .sessioncard__time {
  color: var(--ink);
  font-weight: 600;
}

/* Quiet on the ones not chosen. Three identical full-strength bars is three
   things competing to be looked at; dimmed, they still say how full a sitting
   is without drowning the answer to 「which one did I pick」. */
.sessioncard:not(:has(input:checked)):not(.sessioncard--on) .sessioncard__bar > span {
  opacity: .45;
}

.sessionpick label.sessioncard:focus-within {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* The day you picked stays lit. The calendar no longer disappears when you
   choose one, and a control that keeps no state reads as broken. */
.day.picked {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
}

/* ---------------------------------------------------------------------------
   Counter / settlement

   The balance is the largest number on the screen, because it is the one the
   person at the till is actually reading. Everything else on the page exists
   to explain how it got there.
   --------------------------------------------------------------------------- */
h3.sec {
  font-family: var(--mono); font-size: 0.875rem; letter-spacing: .1em;
  text-transform: uppercase; color: var(--muted); font-weight: 500; margin: 24px 0 10px;
}
h3.sec:first-child { margin-top: 0; }

.billhead {
  background: var(--accent-2); border-radius: var(--radius);
  padding: 14px 16px; margin-bottom: 16px;
  display: flex; justify-content: space-between; gap: 12px;
  align-items: center; flex-wrap: wrap;
}
.billhead b { font-size: 1rem; font-weight: 600; display: block; }
.billhead .sub { font-family: var(--mono); font-size: 0.875rem; color: var(--accent); }

/* Who the bill belongs to.
   ---------------------------------------------------------------------------
   The reference identifies the bill to the system; the name and the number
   identify it to the person about to press 结账. The contact line is one row
   of small type rather than a labelled block — it is there to be *checked*,
   not read, and a label in front of every value would double its height for
   nothing. */
.billhead__who { min-width: 0; }
.billhead__name { font-size: .95rem; margin-top: 3px; }
.billhead__name a { color: inherit; }
.billhead__contact {
  display: flex; flex-wrap: wrap; gap: 4px 14px;
  font-size: 0.875rem; margin-top: 4px;
}
.billhead__contact a { color: var(--accent); }

/* The list of lines measures itself.
   ---------------------------------------------------------------------------
   The stacked row below used to be chosen by viewport width, which is the
   wrong question: this list sits in the left column of a `.grid--2`, so at
   768px the *screen* is wide and the *column* is 354px. The row kept its
   five-column desktop shape in 354px and squeezed the item's name to 38px —
   「東方美人茶（冷泡）大瓶裝」 down eight lines of one character each.

   A container query asks the question that matters — and it is the only copy.
   A viewport-width duplicate kept "as a fallback" is not a fallback: in a
   browser that honours the container query it is dead code, and in one that
   does not it reproduces the 38px name column it was written to fix. Two
   engines, two applications. Container queries have shipped in every engine
   since early 2023, and this stylesheet already depends on `:has()`, which is
   the same vintage.

   470px is the width the four-column row actually needs — name, quantity,
   amount, and the × — measured rather than guessed. Below it the row stacks:
   name across the top, then the quantity block and the amount. */
.blines {
  background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
  overflow: hidden;
  container-type: inline-size; container-name: blines;
}
/* **Every row is its own grid, so `auto` cannot align anything.**
   ---------------------------------------------------------------------------
   The quantity column used to be `auto`. Each `.bline` is a separate grid
   element, so `auto` resolved against *that row's* content — a line at 42.00
   got a wider column than one at 0.00, which took the width out of the 1fr
   name column beside it and left the steppers and the prices stepping in and
   out down the page.

   A fixed track is the fix. Every row now reserves the same width for the
   quantity, whatever is in it, so the buttons stack in a column and the unit
   prices line up on their decimal points. */
.bline {
  /* The quantity track went 152px -> 200px when the steppers went 26px -> 44px.
     The track has to be sized for the buttons, not the other way round: a
     26px stepper beside a line of money is the smallest control on the
     busiest screen in the shop. */
  display: grid; grid-template-columns: 1fr 130px 84px 44px; gap: 10px; align-items: center;
  padding: 11px 13px; border-bottom: 1px solid var(--line); font-size: .9rem;
}
.bline:last-child { border-bottom: 0; }
.bline .nm { line-height: 1.35; }

/* The quantity, editable in place.
   ---------------------------------------------------------------------------
   Same two buttons as the checkout's basket, for the same reason: the number
   is right there and changing it should not mean deleting the line. The unit
   price stays beside them so the row still reads 「2 × 42.00」 at a glance —
   it is what makes the 84.00 on the right make sense. */
.bline .qt--edit {
  display: grid;
  /* − · number · + · × · price */
  /* − · number · + on one row; 「× 42.00」 under them on the next.
     ---------------------------------------------------------------------
     One row of five cells is what this was, and it worked while the steppers
     were 26px. At 44 the reserved track had to grow to 200px — and the bill
     sits in the narrow left-hand column of a two-column screen, so that came
     straight out of the item's name: at 1024px the name fell to a 96px column
     and at 768 to 31px, wrapping a product name to eight lines.

     The unit price is the part that can move. It is still right-aligned in a
     column that is the same width on every row, so the decimal points still
     meet down the page — the invariant money-truth.php protects. */
  grid-template-columns: 44px 2.5ch 44px;
  align-items: center; gap: 4px; row-gap: 2px; white-space: nowrap;
}
/* The × and the number were separate cells so that right-aligning the pair
   would move the digits and not the ×. On a line of their own there is no
   column for a × to hold still against, so the two are one string again and
   the × does drift by up to 30px between rows — which is invisible and would
   not be if the amounts drifted with it. They do not: the right edge of every
   `.qt__unit` is the same on every row, which is the invariant
   `tests/money-truth.php` protects. The comment in bill_show.php describes the
   markup, which is unchanged; this is what the stylesheet does with it. */
.bline .qt--edit .qt__price { display: none; }
.bline .qt--edit .qt__unit { grid-column: 1 / -1; text-align: right; }
.bline .qt--edit .qt__unit::before { content: "× "; }
.bline .qt--edit .qtystep { width: 44px; height: 44px; font-size: .95rem; }
.bline .qt--edit .qtyline__n { min-width: 0; }
.bline .qt__price { font-size: 0.875rem; color: var(--muted); }

/* The × and the amount are two cells, not one string. Right-aligning
   「× 42.00」 as a whole would move the × instead of the digits; splitting them
   pins the × and lets the number sit against the right edge, so 0.00 and 42.00
   share a decimal point. */
.bline .qt__unit {
  font-size: 0.875rem; color: var(--muted);
  text-align: right; font-variant-numeric: tabular-nums;
}

/* The closed bill reads 「1 × 42.00」 in one span, and lines up the same way. */
.bline .qt--read { display: grid; grid-template-columns: 2.5ch 1ch 1fr; gap: 5px; align-items: center; }
.bline .qt--read .n { text-align: right; }

@media (max-width: 759px) {
  /* A thumb, not a mouse — and the reserved track grows to match. */
  .bline { grid-template-columns: 1fr 130px 76px 44px; gap: 8px; }
}

/* **On a phone the name gets its own line.**
   ---------------------------------------------------------------------------
   Four columns on a 390px screen leaves about sixty pixels for the tea, and
   「凤凰单丛 蜜兰香」 wraps to three lines beside a row of buttons. The numbers
   are the part that has to stay in a column, so they keep the second row to
   themselves and the name takes the first. */
@container blines (max-width: 470px) {
  .bline {
    /* A fixed money column, not max-content: 「0.00」 and 「2016.00」 are
       different widths, and letting them size their own column moves the
       boundary — which drags the unit price beside it out of line, row by row.
       That is the thing this whole change exists to stop. */
    grid-template-columns: 1fr 84px 44px;
    grid-template-areas:
      "nm  nm  rm"
      "qt  amt amt";
    row-gap: 9px; column-gap: 8px;
  }
  /* The row gives back three pixels a side, the money column eight and the
     steppers a pixel of gap, which together is what a 320px screen needs in
     English — where a description cannot break between characters the way
     Chinese can — once the buttons are the size a thumb wants. Measured: the
     row's min-content was 300px inside a 280px column. */
  .bline { padding-left: 10px; padding-right: 10px;
           grid-template-columns: 1fr 76px 44px; column-gap: 6px; }
  .bline .qt--edit { gap: 3px; row-gap: 2px; }
  .bline .nm  { grid-area: nm; }
  .bline .qt  { grid-area: qt; }
  .bline .amt { grid-area: amt; }
  .bline > form:last-child,
  .bline > span:last-child { grid-area: rm; }
}

.bline .kd {
  font-family: var(--mono); font-size: 0.875rem; letter-spacing: .06em;
  text-transform: uppercase; display: block; margin-top: 2px;
}
.bline .kd.ses { color: var(--accent); }
.bline .kd.ret { color: var(--muted); }
.bline .kd.opn { color: var(--amber); }
.bline .qt, .bline .amt {
  font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 0.875rem;
}
.bline .amt { text-align: right; font-weight: 500; }
.bline form { margin: 0; }
.bline .rm {
  appearance: none; border: 0; background: transparent; color: var(--line-2);
  cursor: pointer; font-size: 1rem; line-height: 1; padding: 0;
  min-width: 44px; min-height: 44px;
  display: inline-flex; align-items: center; justify-content: center;
}
.bline .rm:hover { color: var(--red); }
.bempty { padding: 28px 16px; text-align: center; color: var(--muted); font-size: 0.875rem; }

.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 9px; }
.tiles form { margin: 0; }
.tile {
  appearance: none; font: inherit; text-align: left; cursor: pointer; color: var(--ink);
  background: var(--card); border: 1px solid var(--line-2); border-radius: var(--radius-sm);
  padding: 12px 13px; display: flex; flex-direction: column; gap: 4px;
  min-height: 70px; width: 100%;
}
.tile:hover:not(:disabled) { border-color: var(--accent); }
.tile .tn { font-size: 0.875rem; font-weight: 500; line-height: 1.3; }
.tile .tp { font-family: var(--mono); font-size: 0.875rem; color: var(--amber); margin-top: auto; }
.tile .tx { font-size: 0.875rem; color: var(--muted); }
.tile.add { border-style: dashed; color: var(--muted); }
.tile.out { opacity: .4; cursor: not-allowed; }

.totals { background: var(--soft); border-radius: var(--radius); padding: 15px 17px; margin-top: 16px; }
.trow { display: flex; justify-content: space-between; gap: 12px; font-size: .9rem; padding: 4px 0; }
.trow span:first-child { color: var(--muted); }
.trow .v { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.trow.pay .v { color: var(--accent); }
.trow.min, .trow.min span:first-child { color: var(--amber); }

/* A discount, under the subtotal where a till receipt puts it.
   ---------------------------------------------------------------------------
   Amber, because it is money — the same rule as every other figure. The little
   × is set quiet and inline: removing a discount is a correction, not one of
   the things being done all day, and it should not read like a control the eye
   has to step over on the way to the total. */
.trow.disc, .trow.disc span:first-child { color: var(--amber); }
.rm--inline {
  background: none; border: 0; padding: 0 0 0 6px; cursor: pointer;
  color: var(--muted); font-size: .9rem; line-height: 1;
}
.rm--inline:hover { color: var(--ink); }
.trow.sum {
  border-top: 1px solid var(--line-2); margin-top: 7px; padding-top: 10px;
  font-weight: 600; font-size: .96rem;
}
.trow.sum span:first-child { color: var(--ink); }

.balbar {
  display: flex; justify-content: space-between; align-items: baseline; gap: 12px;
  border-top: 2px solid var(--line-2); margin-top: 11px; padding-top: 13px;
}
.balbar .lab { font-size: 0.875rem; color: var(--muted); }
/* Amber is money, and this is the most-read money in the building — the balance
   still owing, at the till, with a guest waiting. It had no colour rule at all,
   so it rendered in plain --ink while the product tiles 200px below it were
   correctly amber. Zero stays accent green: that reads as settled. */
.balbar .big {
  font-family: var(--mono); font-size: 1.9rem; font-weight: 600;
  font-variant-numeric: tabular-nums; line-height: 1; letter-spacing: -.02em;
  color: var(--amber);
}
.balbar .big.zero { color: var(--accent); }

.paymethods { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 9px; }
.pm {
  font-size: .9rem; font-weight: 500; cursor: pointer; color: var(--ink);
  background: var(--card); border: 1px solid var(--line-2); border-radius: var(--radius-sm);
  padding: 14px 12px; text-align: center; margin: 0; display: block;
}
.pm:hover { border-color: var(--accent); color: var(--accent); }
.pm.sel { background: var(--accent-2); border-color: var(--accent); color: var(--accent); }

.bigin {
  width: 100%; font-family: var(--mono); font-size: 1.5rem; font-weight: 600; text-align: right;
  border: 1px solid var(--line-2); border-radius: var(--radius); padding: 14px 15px;
  background: var(--card); color: var(--ink); font-variant-numeric: tabular-nums;
}
.changerow {
  display: flex; justify-content: space-between; align-items: baseline;
  background: var(--accent-2); border-radius: var(--radius-sm); padding: 13px 15px; margin-top: 12px;
}
.changerow .lab { font-size: 0.875rem; color: var(--accent); }
.changerow .v {
  font-family: var(--mono); font-size: 1.35rem; font-weight: 600; color: var(--accent);
  font-variant-numeric: tabular-nums;
}
.roundnote { font-size: 0.875rem; color: var(--amber); margin: 9px 0 0; }

/* --- Document rows --------------------------------------------------------- */
.docrow {
  display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: center;
  padding: 13px 0; border-bottom: 1px solid var(--line);
}
.docrow:last-child { border-bottom: 0; }
.docrow .no { font-family: var(--mono); font-size: 0.875rem; font-weight: 500; }
.docrow .dt2 { font-family: var(--mono); font-size: 0.875rem; color: var(--muted); margin-top: 2px; }
.docrow .amt2 { font-family: var(--mono); font-size: .9rem; font-weight: 500; text-align: right; }

/* --- Pills, the kit's shape ------------------------------------------------ */
.pill.ok   { background: var(--accent-2); color: var(--accent); }
.pill.warn { background: var(--amber-2);  color: var(--amber); }
.pill.bad  { background: var(--red-2);    color: var(--red); }

/* --- Buttons, the kit's secondary -------------------------------------------- */
.btn.ghost { background: transparent; border: 1px solid var(--line-2); color: var(--ink); font-weight: 500; }
.btn.ghost:hover { background: var(--soft); border-color: var(--accent); color: var(--ink); }
.btn.sm { font-size: 0.875rem; padding: 10px 14px; min-height: 0; width: auto; }

/* ---------------------------------------------------------------------------
   Receipt document

   Monospace throughout so the columns line up, dashed rules where a paper
   receipt would tear. Frozen at issue — every value here comes from the
   snapshot taken when the document was created, never from live data.
   --------------------------------------------------------------------------- */
.doc {
  background: var(--card); border: 1px solid var(--line); border-radius: 8px;
  padding: 22px; font-family: var(--mono); font-size: 0.875rem;
  box-shadow: var(--sh); max-width: 420px; margin: 0 auto;
}
.doc .dh { text-align: center; border-bottom: 1px dashed var(--line-2); padding-bottom: 13px; margin-bottom: 13px; }
.doc .dh b { font-family: var(--serif); font-size: 1rem; font-weight: 600; display: block; margin-bottom: 3px; }
/* `> span`, not `span`. As a descendant selector this also caught the
   `<span class="nb">` around each company registration number, and turned
   「(202303062042 / JM0980447-T)」 into five stacked lines on every receipt. */
.doc .dh > span { color: var(--muted); font-size: 0.875rem; display: block; line-height: 1.6; }
/* The meta line: 「收据 RCP-2026-0003」 on the left, the date on the right.
   Each half stays whole and the pair folds instead — a receipt number broken
   after its hyphen, across two lines, is a receipt number somebody reads back
   wrongly over the phone. */
.doc .dm { display: flex; justify-content: space-between; gap: 4px 10px; flex-wrap: wrap;
           font-size: 0.875rem; color: var(--muted); margin-bottom: 8px; }
.doc .dm > span { white-space: nowrap; }
/* Anything that is one identifier and must not be read back in two halves. */
.nb { white-space: nowrap; }

.doc dl { display: grid; grid-template-columns: 1fr auto auto; gap: 0 12px; margin: 8px 0 0; font-size: 0.875rem; }

/* A receipt is read on a phone, and it is the document a customer forwards.
   The type went up 26% in 058 while the padding stayed at 22px a side, which
   took 44px out of a 350px card and started breaking codes mid-token.

   After `.doc dl` above and not before it: the two have the same specificity,
   so written first this block lost on source order and the phone receipt kept
   a three-track grid it was supposed to have folded into two. */
@media (max-width: 430px) {
  .doc { padding: 16px 12px; }

  /* The name takes the whole width and the numbers go under it.
     ---------------------------------------------------------------------
     A line's description is where a voucher code lives — 「电子券 · TGNRU-97WRJ」
     — and a code broken after its hyphen, over two lines, is a code somebody
     reads back wrongly down the phone. A receipt is customer-side and so at
     the 16px root, where `.doc` computes to 14px: that string measures 157px
     and the name column had about 150 on a 320px screen once the quantity and
     the amount had taken theirs. On its own row it has 296. */
  .doc dl { grid-template-columns: 1fr auto; }
  .dline .nm { grid-column: 1 / -1; padding-bottom: 0; }
}
.dline { display: contents; }
.dline > * { padding: 4px 0; }
.dline .r { text-align: right; font-variant-numeric: tabular-nums; }
.dline .nm { font-family: var(--sans); }
.docsep { grid-column: 1 / -1; border-top: 1px solid var(--line); margin: 6px 0 2px; }
.docsep.dash { border-top-style: dashed; border-color: var(--line-2); }
.dline.tot > * { font-weight: 600; font-size: .9rem; }
.dline.pay > * { color: var(--accent); }
.dline.min > * { color: var(--amber); }

/* A note on the document — the reason a refund was given, or the sentence
   saying what the document is. Set in the reading face rather than the
   monospace: it is prose, not a column. */
.doc .dnote {
  font-family: var(--sans); font-size: 0.875rem; line-height: 1.6;
  color: var(--ink-2); margin-top: 12px; padding-top: 10px;
  border-top: 1px dashed var(--line-2);
}
.doc .dnote b { color: var(--muted); font-weight: 500; }

.doc .df {
  border-top: 1px dashed var(--line-2); margin-top: 13px; padding-top: 12px;
  text-align: center; color: var(--muted); font-size: 0.875rem; line-height: 1.7;
}

@media print {
  .doc { border: 0; box-shadow: none; max-width: none; padding: 0; }
}

/* The way out of a document page.
   ---------------------------------------------------------------------------
   The order page is rendered in the printable layout, which has no header and
   no tab bar — correct for a document, and a dead end at the exact moment it
   matters most, which is the minute after somebody has paid. The foot gives
   the page somewhere to go and takes itself off the paper. */
.docnav {
  max-width: 420px; margin: 18px auto 0;
  display: flex; flex-direction: column; gap: 8px;
}
@media print { .docnav { display: none; } }

/* ---------------------------------------------------------------------------
   Native controls

   A tick box left alone is Windows blue, and on a dark ground a number field's
   spinner arrows are invisible until you hunt for them. These are the details
   that make an interface feel like nobody looked at it.
   --------------------------------------------------------------------------- */
input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); width: auto; }

input[type="number"] { -moz-appearance: textfield; }
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

/* Inputs sit *in* the card rather than beside it. The previous ground was so
   close to the page colour that a filled field read as disabled. */
input[type="text"], input[type="email"], input[type="password"], input[type="number"],
input[type="time"], input[type="date"], input[type="datetime-local"], input[type="search"],
select, textarea {
  background: var(--card);
  border: 1px solid var(--line-2);
  color: var(--ink);
}
input::placeholder, textarea::placeholder { color: var(--muted); opacity: 1; }
input:disabled, select:disabled, textarea:disabled {
  background: var(--soft); color: var(--muted); cursor: not-allowed;
}
input[type="file"] { color: var(--ink-2); font-size: 0.875rem; }
input[type="file"]::file-selector-button {
  font: inherit; font-size: 0.875rem; margin-right: 10px;
  background: var(--soft); color: var(--ink); cursor: pointer;
  border: 1px solid var(--line-2); border-radius: 7px; padding: 6px 12px;
}
input[type="file"]::file-selector-button:hover { border-color: var(--accent); color: var(--accent); }

/* --- One height for every control -------------------------------------------
   A row of a select, a time picker and a number box came out 39px, 47px and
   45px tall, because each of the three is drawn by the browser with its own
   internal padding. Side by side in a `.field-row` that reads as a form
   somebody assembled by accident.

   So the height is stated rather than inherited. `height` and not `min-height`,
   because the native time and date pickers grow past a minimum and would go on
   being the tall one in the row. Textareas, file inputs and checkboxes are
   excluded — they are not single-line controls and should not pretend to be.
   --------------------------------------------------------------------------- */
:root { --control-h: 44px; }

input[type="text"], input[type="email"], input[type="password"], input[type="number"],
input[type="time"], input[type="date"], input[type="datetime-local"],
input[type="search"], input[type="tel"], input[type="url"], select {
  height: var(--control-h);
  line-height: 1.2;
}

/* Buttons that sit in a field row line up with the fields beside them. */
.field-row .btn, .ranger__form .btn { height: var(--control-h); }

/* The pax stepper keeps its own 48px: it is a thumb target on the public
   booking form and stands alone there, never in a row beside a select.
   `.stepper .v` outranks the rule above on specificity, which is why it does
   not need restating here. */

textarea { min-height: calc(var(--control-h) * 2); }

/* ---------------------------------------------------------------------------
   Print

   Anything printed comes out on white paper regardless of what the screen was
   doing. A dark receipt prints as a black rectangle and empties a cartridge
   doing it.
   --------------------------------------------------------------------------- */
@media print {
  /* All three stamps, not the bare `:root`.
     `:root[data-theme="dark"]` is (0,2,0) and beats a bare `:root` (0,1,0)
     whatever the source order, so before 058 this block simply never applied
     to the back office — which was permanently dark — and after it, it applied
     or not depending on which theme the operator had chosen. A dark receipt
     prints as a black rectangle and empties a cartridge doing it. */
  :root, :root[data-theme="dark"], :root[data-theme="light"] {
    --ground: #FFFFFF; --app: #FFFFFF; --card: #FFFFFF; --soft: #F4F6F2;
    --ink: #111111; --ink-2: #333333; --muted: #666666;
    --line: #DDDDDD; --line-2: #BBBBBB;
    --accent: #1F4A3A; --accent-2: #EEF4F0; --on-accent: #FFFFFF;
    --amber: #7A5210; --amber-2: #F8F2E6;
    --red: #8A2F27; --red-2: #F9EDEB;
    --sh: none;
  }
  body { background: #fff; color: #111; }
  .site-header, .site-footer, .tabbar, .adminnav, .uplink, .btn, .prog, .docactions { display: none !important; }
  .card, .doc { border: 0; box-shadow: none; }
  a[href]::after { content: ""; }
}

/* --- Date range filter ----------------------------------------------------
   Eight presets and a custom pair, on every list with a date. Pills rather than
   a <select>, because on a phone a select is two taps and a scroll wheel and
   these are the eight things people actually pick. The row scrolls sideways
   rather than wrapping to three lines on a narrow screen.
   ------------------------------------------------------------------------- */
.ranger {
  display: flex; align-items: flex-start; gap: 8px; flex-wrap: wrap;
  margin-bottom: 16px;
}
.ranger__pills {
  display: flex; gap: 8px; flex-wrap: wrap;
  padding-bottom: 2px;
}
.ranger__pills::-webkit-scrollbar { display: none; }

.rpill {
  display: inline-flex; align-items: center; white-space: nowrap;
  padding: 9px 18px; min-height: 44px;
  border-radius: 999px;
  border: 1.5px solid var(--line-2);
  background: transparent; color: var(--ink-2);
  font-size: .9rem; font-weight: 500; text-decoration: none;
  font-variant-numeric: tabular-nums;
  cursor: pointer; list-style: none;
}
.rpill::-webkit-details-marker { display: none; }
.rpill:hover { border-color: var(--accent); color: var(--ink); }

/* The chosen one is filled, not tinted. On a row of eight outlines the eye
   needs a solid to land on, which is the whole job of this control. */
.rpill--on {
  background: var(--accent); border-color: var(--accent);
  color: var(--on-accent); font-weight: 600;
}
.rpill--on:hover { background: var(--accent-hi); border-color: var(--accent-hi); color: var(--on-accent); }

/* Direction. Deliberately not filled — it is a state, not a choice among
   eight, and a second solid pill on this row would compete with the range for
   the eye.
   It used to be pushed to the far end with margin-left:auto, which is exactly
   what forced it onto a line of its own on a phone. In the queue beside 更多 it
   fits, and there are only three things ahead of it now. */
.rpill--sort { gap: 7px; padding-left: 14px; }
.rpill--sort svg { width: 16px; height: 16px; }

/* On a phone the content column is 308px, and 今日 + 全部 + 更多 + 最新在前
   needs 353. Something has to give, and the honest thing to drop is the word:
   the arrow is already drawn two different ways for the two directions, so it
   carries the whole meaning on its own. The words come back the moment there
   is room for them, and the title and aria-label are there in both cases. */
@media (max-width: 559px) {
  .rpill--sort .rpill__lbl { display: none; }
  .rpill--sort { padding: 9px 13px; }
  /* Four pills in 278px on the narrowest phone still sold — two characters of
     padding each is what buys it, and 16px is still a comfortable target. The
     40px minimum height, which is the one that matters for a thumb, is
     untouched. */
  .ranger .rpill { padding-left: 16px; padding-right: 16px; }
}

/* A description the shop wrote, with a little formatting in it. Bounded so a
   pasted list cannot wander off the card's own rhythm. */
.richtext > :first-child { margin-top: 0; }
.richtext > :last-child { margin-bottom: 0; }
.richtext p { margin: 0 0 .8em; }
.richtext ul, .richtext ol { margin: 0 0 .8em; padding-left: 1.4em; }
.richtext li { margin-bottom: .3em; }
.richtext a { color: var(--accent); }
.richtext b, .richtext strong { font-weight: 600; color: var(--ink); }

/* --- Reports --------------------------------------------------------------
   A bar, a percentage and an amount on one line. The bar is not decoration: it
   is the only part that answers "which of these three is carrying the shop"
   without reading three numbers and doing arithmetic. */
.srcrow {
  display: flex; align-items: center; gap: 12px;
  padding: 9px 0;
  border-top: 1px solid var(--line);
}
.srcrow:first-of-type { border-top: 0; }
.srcrow__name { flex: 0 0 5.5em; }
.srcrow__bar {
  flex: 1 1 auto; height: 8px; min-width: 40px;
  border-radius: 999px; background: var(--soft); overflow: hidden;
}
.srcrow__bar > span { display: block; height: 100%; background: var(--accent); border-radius: 999px; }
.srcrow__pct { flex: 0 0 3em; text-align: right; color: var(--muted); font-size: 0.875rem; }
.srcrow__amt { flex: 0 0 auto; text-align: right; font-variant-numeric: tabular-nums; }

/* Two cards side by side where there is room, stacked where there is not. */
.repcols { display: grid; gap: 0 18px; grid-template-columns: 1fr; }
@media (min-width: 860px) { .repcols { grid-template-columns: 1fr 1fr; } }

/* The one number anybody actually came for. */
.tile--wide { grid-column: span 2; }
.tile--wide .tile__value { font-size: 1.9rem; }

@media (max-width: 559px) {
  .srcrow__pct { display: none; }
  .srcrow__name { flex-basis: 4.5em; }
}

/* A count riding on a sub-tab: "is there anything in drafts" answered without
   clicking into drafts. */
.rpill__n {
  margin-left: 7px; padding: 1px 7px;
  border-radius: 999px; background: var(--soft);
  font-size: 0.875rem; font-variant-numeric: tabular-nums;
}
.rpill--on .rpill__n { background: rgba(0,0,0,.16); }

/* A row of buttons that belong to different forms. */
.formrow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
.formrow form { margin: 0; }

/* --- The event editor -----------------------------------------------------
   Once published the whole detail form folds into one line, so what is left on
   screen is 訂單 and 出席名單 — the two panels that are actually used on the
   day. The card inside the fold loses its own frame; two nested borders read as
   a mistake. */
.evtfold > .fold__body > form > .card {
  border: 0; padding: 0; margin-bottom: 18px; background: transparent;
}
.evtactions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.evtactions--publish {
  margin-top: 16px; padding-top: 16px;
  border-top: 1px solid var(--line);
}
.evtactions form { margin: 0; }

/* --- The scanner at the door ----------------------------------------------
   Folded away until pressed: the video element is 0px tall and the frame has no
   border, so a check-in page that is never scanned from does not carry a black
   rectangle around all day. */
.scanner { margin-top: 18px; }
.scanner__frame {
  position: relative; overflow: hidden;
  border-radius: var(--radius);
  background: #000;
  max-height: 0; transition: max-height .2s ease;
}
.scanner.is-live .scanner__frame { max-height: 340px; margin-bottom: 10px; }
.scanner__frame video { display: block; width: 100%; max-height: 340px; object-fit: cover; }

/* Where to hold the ticket. Nothing functional — the decoder reads the whole
   frame — but people aim at a box, and without one they hold the phone too far
   back and nothing reads. */
.scanner__reticle {
  position: absolute; inset: 50% auto auto 50%;
  transform: translate(-50%, -50%);
  width: 190px; height: 190px; max-width: 62%;
  border: 2px solid rgba(255,255,255,.85);
  border-radius: 12px;
  box-shadow: 0 0 0 9999px rgba(0,0,0,.35);
  pointer-events: none;
}
.scanner:not(.is-live) .scanner__reticle { display: none; }

.scanner__bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }

/* --- Pager ----------------------------------------------------------------
   The count prints even on a single page, and that is the point. These lists
   used to stop at 200 rows in silence, so with 1,832 bookings the screen showed
   six weeks of 2023 and looked like the whole thing. A number under the table
   is the cheapest defence against believing a list you have only seen part
   of. */
.pager {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
  margin-top: 14px; padding-top: 12px;
  border-top: 1px solid var(--line);
}
.pager__count {
  margin: 0; color: var(--muted); font-size: 0.875rem;
  font-variant-numeric: tabular-nums;
}
.pager__pages { display: flex; gap: 4px; flex-wrap: wrap; }

.ppill {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 44px; height: 44px; padding: 0 10px;
  border: 1px solid var(--line-2); border-radius: var(--radius-sm);
  color: var(--ink-2); text-decoration: none;
  font-size: .9rem; font-variant-numeric: tabular-nums;
}
.ppill:hover { border-color: var(--accent); color: var(--ink); }
.ppill--on {
  background: var(--accent); border-color: var(--accent);
  color: var(--on-accent); font-weight: 600;
}
.ppill--gap { border-color: transparent; color: var(--muted); min-width: 26px; }

@media (max-width: 559px) {
  .pager { flex-direction: column; align-items: stretch; }
  .pager__pages { justify-content: center; }
}

/* --- Search over a whole list --------------------------------------------- */
.listsearch {
  display: flex; gap: 8px; align-items: stretch;
  margin-bottom: 14px;
}
.listsearch input[type="search"] { flex: 1 1 auto; min-width: 0; }
.listsearch .btn { flex: 0 0 auto; }

/* --- 更多 ------------------------------------------------------------------
   Six of the eight ranges and the custom form live behind one button. The
   panel is anchored to the *row*, not to the button: 更多 sits two-thirds of
   the way along a 390px row, and a panel hung off it runs straight off the
   side of the screen. */
.ranger { position: relative; }
.ranger__more { position: static; flex: 0 0 auto; }
.ranger__more > summary { list-style: none; }
.ranger__more > summary::-webkit-details-marker { display: none; }
.rpill--more { gap: 7px; }
/* Sized in rem, not em. At .68em this drew at 11.02px on every screen with a
   date range on it, and a floor that only reads rem could never see it. */
.rpill__caret { font-size: .875rem; opacity: .8; }
/* Turned the other way up while it is open, so the button says which way it
   goes rather than only which way it went. */
.ranger__more[open] .rpill__caret { transform: rotate(180deg); }

.ranger__panel {
  position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 15;
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--sh);
  padding: 13px;
  animation: rangerdrop .16s ease;
}
@keyframes rangerdrop {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: none; }
}
.ranger__panel > .ranger__pills { margin-bottom: 13px; }

.ranger__form {
  display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap;
  border-top: 1px solid var(--line); padding-top: 13px; margin: 0;
}
/* 150px was right for one text box and is a third of what a date needs now
   that it is three. */
.ranger__form .field { margin-bottom: 0; width: 300px; }

@media (max-width: 700px) {
  .ranger__form .field { width: 100%; }
  .ranger__form .btn { margin-left: auto; }
}

.ranger__showing { margin: -8px 0 14px; }

@media (max-width: 559px) {
  /* On a phone the pop-over would hang off the edge, so it becomes a block
     that pushes the list down — annoying for a second, correct for a second. */
  .ranger { flex-wrap: wrap; }
  /* Closed, 自訂 stays in the pill row where it belongs. Open, it takes the
     full width so the two date fields have somewhere to go. */
  .ranger__custom { width: auto; }
  .ranger__custom[open] { width: 100%; }
  .ranger__form { position: static; flex-wrap: wrap; }
  /* One date per line. Two of them side by side leaves each about 140px, which
     is not enough for three boxes and stacks them three high. */
  .ranger__form .field { width: 100%; flex: 1 1 100%; }
}

/* An event's section headings. Quieter than a page heading and closer to the
   text under them: they are signposts through one description, not four
   separate articles. */
.evtsec {
  font-size: 1rem;
  margin: 22px 0 0;
  color: var(--ink-2);
  letter-spacing: .04em;
}
.evtsec + .richtext { margin-top: 6px; }

/* --- A row that opens the thing it describes --------------------------------
   Pressing anywhere on the row goes where the row's own link goes. The link is
   the booking number, and it is a real anchor — keyboard, middle-click and
   open-in-new-tab all work on it whether or not the script loaded. This only
   says the row is pressable and lights it under the finger.
   ------------------------------------------------------------------------- */
tr[data-rowlink] { cursor: pointer; }
tr[data-rowlink]:hover { background: var(--soft); }

/* Keyboard focus lands on the anchor, so the row it sits in is what should
   look focused — otherwise the highlight is eight characters wide on a row the
   whole width of the screen. */
tr[data-rowlink]:focus-within { background: var(--soft); }

@media (hover: none) {
  /* No hover on a touch screen, and a sticky :hover left rows lit after a tap.
     The press itself is feedback enough there. */
  tr[data-rowlink]:hover { background: transparent; }
  tr[data-rowlink]:active { background: var(--soft); }
}

/* --- Tables that become cards on a phone -----------------------------------
   A five-column table on a 390px screen is a horizontal scrollbar, and a
   horizontal scrollbar is a column nobody ever reads. Below 560px each row
   becomes a small card and each cell carries its own label, so the same markup
   serves the counter iPad and the owner's phone.

   Opt in with class="cardtable" and give every cell a data-label. A cell with
   data-label="" prints no label — that is for a status pill or a row of
   buttons, which say what they are on their own.
   ------------------------------------------------------------------------- */
@media (max-width: 559px) {
  .cardtable, .cardtable tbody, .cardtable tr, .cardtable td { display: block; width: 100%; }
  .cardtable thead { display: none; }

  .cardtable tr {
    border: 1px solid var(--line); border-radius: var(--radius-sm);
    padding: 12px 14px; margin-bottom: 10px; background: var(--app);
  }
  .cardtable tr:last-child { margin-bottom: 0; }

  .cardtable td { border: 0; padding: 3px 0; }
  .cardtable td.num { text-align: left; }
  /* Some action cells carry an inline text-align:right for the wide table.
     Nothing but !important outranks an inline style, and in a stacked card
     everything reads from the left. */
  .cardtable td[style*="text-align:right"] { text-align: left !important; }
  .cardtable td:empty { display: none; }

  .cardtable td[data-label]::before {
    content: attr(data-label) ' ';
    display: inline-block; min-width: 5.5em;
    color: var(--muted); font-size: 0.875rem; font-family: var(--sans);
  }
  .cardtable td[data-label='']::before { content: none; }

  /* The first cell is the one you scan for, so it leads. */
  .cardtable td:first-child { font-size: 1rem; padding-bottom: 6px; }

  /* Buttons inside a card row get a real tap target rather than a 12px link. */
  .cardtable td form { display: inline-block; margin: 4px 6px 0 0; }
  .cardtable .btn--link { padding: 6px 2px; }

  /* Unless the cell holds a fold, and the fold holds a real form.
     The rule above was written for a cell containing one or two small buttons,
     and it is a descendant selector, so it also reaches the password form
     inside the fold on 登入的人 — turning a labelled field and its button into
     shrink-to-fit inline-blocks, with the input's width:100% resolving against
     a box that has no width of its own. A form somebody types into is a block. */
  .cardtable td .fold__body form { display: block; margin: 0 0 12px; }
  .cardtable td .fold__body form:last-child { margin-bottom: 0; }
}

/* --- Empty states ---------------------------------------------------------
   "沒有預約。" set as ordinary muted text reads like a paragraph that failed to
   load. Given a dashed frame and some air it reads as a state: this list is
   working, there is simply nothing in it yet.
   ------------------------------------------------------------------------- */
.empty {
  margin: 0;
  padding: 22px 16px;
  text-align: center;
  color: var(--muted);
  font-size: .92rem;
  border: 1px dashed var(--line-2);
  border-radius: var(--radius-sm);
  background: transparent;
}
.empty__hint { display: block; font-size: 0.875rem; margin-top: 4px; opacity: .85; }

/* --- Hit areas ------------------------------------------------------------
   A 12px text link is a fine target for a mouse and a poor one for a thumb.

   Two things were wrong with this block, and they were wrong together.

   The floor was **40px**, where the number everyone else uses is 44 — Apple's
   HIG, and the WCAG 2.2 target-size minimum. Four pixels sounds like nothing;
   it is 20% more area, and it is the difference between the middle of a thumb
   and the edge of one.

   And the whole block sat inside `@media (max-width: 759px)`, so it applied to
   phones and to nothing else. The back office is used on a tablet propped by
   the till and on a laptop at the end of the day — landscape, 1024px wide, no
   floor at all. A hand does not become steadier because the glass got wider.

   So: 44, everywhere. The visual weight barely changes, because on almost
   every control here the extra height is padding that was already nearly
   there. Where it is not — the pager, the quantity steppers, the language
   switch, the settings sub-tabs — those were genuinely too small and now are
   not.

   Measured before this change: 24 controls under 44 × 44. The worst was
   `.rm--inline` at 14.4 × 20.6px, and that one deletes money off a bill.
   ------------------------------------------------------------------------- */
.btn, .rpill, .subnav a, .adminnav a { min-height: 44px; display: inline-flex; align-items: center; }
/* Width as well as height. A two-character Chinese label, or a bare ‹ or ›,
   comes to about 32px across however tall the box is. */
.btn, .rpill { min-width: 44px; justify-content: center; }
/* A width as well as a height. 作废, 停用, 略过 and 删除 are all two Chinese
   characters, which came to 39px across — tall enough and not wide enough. */
.btn--link { min-height: 44px; min-width: 44px; justify-content: center;
             padding-left: 4px; padding-right: 4px; }
.fold__head { min-height: 48px; }
/* The shop's name in the corner is the way home from every screen in the back
   office, and it was 40px because the mark inside it is 40px. */
.brand { min-height: 44px; }
/* A pill that is a link is a control. The three in the header — the cron
   warning, the queued-messages count, the pending-upgrade notice — are the
   fastest route to the three things that mean the shop is not working, and
   they measured 33px. A pill that only *states* a status, which is most of the
   84 of them, is text and stays as it is. */
a.pill, button.pill { min-height: 44px; display: inline-flex; align-items: center; }
/* `.small` is usually a span — a count, a note beside a heading. Where it is a
   link or a button it is still a control, and the one on the month calendar
   (back to this month) measured 32 x 28. */
a.small, button.small { min-height: 44px; min-width: 44px; display: inline-flex;
                        align-items: center; justify-content: center; }
/* Every <summary> is a control — it opens something. `.guest__note > summary`,
   the 加内部备注 disclosure on the busiest screen in the shop, measured 34px. */
summary { min-height: 44px; }
/* The guest's name on the Today screen is the way into that booking, and the
   two-character buttons beside it wrote the floor back down to 36px in a rule
   that comes after this block. */
.guest__name { min-height: 44px; display: inline-flex; align-items: center; }
.guest__do .btn, .guest__do > .pill { min-height: 44px; }
/* The reference, the name and the phone number at the top of a bill are all
   links, and all of them were between 19 and 28px tall. */
.billhead__name a, .billhead__contact a { min-height: 44px; display: inline-flex; align-items: center; }
/* A name in a list is the target for that row. `.cardtable` stacks into cards
   on a phone, where the link is the whole reason the card is there. Scoped to
   cells so a link inside a sentence — which WCAG exempts, and which would look
   absurd at 44px — is left alone. */
.cardtable td a, .cardtable td button, .rows td a { min-height: 44px; display: inline-flex; align-items: center; }

/* The small round or square things: easy to draw, hard to hit. The slideshow
   arrows and dots, the calendar's month arrows, the switches on the templates
   screen, a chip you can press, the close cross on a panel. */
.switch, .chipswitch { min-height: 44px; }
.calnav { min-width: 44px; min-height: 44px; }
.slides__arrow, [data-slides-prev], [data-slides-next] { min-width: 44px; min-height: 44px; }
/* The dot's own box is already the target — see .slides__dot. Left here as
   a floor for anything else that carries the attribute. */
[data-slides-go] { min-width: 44px; min-height: 44px; }
[data-dialog-close] { min-width: 44px; min-height: 44px; justify-content: center; }
a.chip, button.chip, label.check { min-height: 44px; display: inline-flex; align-items: center; }

/* A checkbox itself cannot be padded into a target, so the box grows. The
   label around it is already the hit area; this is for the tap that lands on
   the box and misses. It was ~13px at rest and 22px on phones only. */
/* Every one of them, not only the ones inside a `.check` row. A bare
   `<input type="checkbox">` renders at the browser's own 13 × 13px — that is
   what the 规格 table's 上架 column was — and 24px is as large as a native box
   goes before it stops looking like a checkbox. The label or the row beside it
   carries the rest of the target. */
input[type="checkbox"], input[type="radio"] { width: 24px; height: 24px; }

/* `.qtystep`, `.subpill` and `.ppill` are set further down and are corrected
   at their own rules, not here — a floor written above the thing it is meant
   to raise does nothing. */
/* A `min-height` on a `display: block` anchor does not centre what is inside
   it — a button centres its own content and an anchor does not, so 中文 / EN
   sat jammed against the top of a 44px lozenge while 白天 / 晚上 sat in the
   middle of an identical one, 12px apart, in the same header. */
/* A width as well as a height. Each half of a segmented control is its own
   target — 中文 and EN are two separate presses — and 中文 is two characters
   across, which is 36px however tall the box is. */
.langpill__opt { min-height: 44px; min-width: 44px; padding: 0 15px;
                 display: inline-flex; align-items: center; justify-content: center; }

/* The two × controls that take money off a bill.
   ---------------------------------------------------------------------------
   Both were first given an invisible `::after` reaching out past the glyph,
   which is the trick this file already used for `.bline .rm`. It was the wrong
   answer twice over: it never quite reached 44 in both directions, and — worse
   — an invisible box taller than its own row **overlaps the row underneath**.
   Measured on a bill with two discounts, a tap in the bottom three pixels of
   the first discount deleted the second one. An invisible target that steals a
   press from its neighbour is more dangerous than a small one, because the
   operator has no way to see that it happened.

   So both are real boxes now, centred, and their rows are tall enough to hold
   them. The glyph is unchanged; the row grew, which the totals block had room
   for anyway. */
.rm--inline {
  min-width: 44px; min-height: 44px; padding: 0 0 0 6px;
  display: inline-flex; align-items: center; justify-content: center;
}
.trow { min-height: 44px; align-items: center; }

@media (max-width: 759px) {
  /* Anything under 16px in a form field makes iOS zoom the page on focus, and
     the reader then has to pinch back out. 1rem is 16px on the customer side
     and 18px behind the counter — over the line either way, and it tracks the
     reading size instead of being a number typed once. */
  input, select, textarea, button { font-size: 1rem; }
}

/* --- Motion ---------------------------------------------------------------
   Small and few. A button that acknowledges a press and a card that lifts a
   little under the cursor; nothing that animates on load, because a list of
   today's bookings that fades in is a list you cannot read yet.
   ------------------------------------------------------------------------- */
.btn, .rpill, .adminnav a, .subnav a, .daycard, .sessioncard, .day {
  transition: background-color .14s ease, border-color .14s ease, color .14s ease;
}
.btn:active, .rpill:active { transform: translateY(1px); }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
    scroll-behavior: auto !important;
  }
  .btn:active, .rpill:active { transform: none; }
}

/* --- Phone number ----------------------------------------------------------
   One number, one control. The dialling code lives inside the left edge of the
   same box as the digits, sharing a border and a focus ring — two labelled
   fields side by side made a Malaysian phone number look like two questions,
   and put the less interesting one first.

   The select is sized in ch of its own text so `+852` still fits, and the
   negative margin collapses the two borders into a single divider.
   ------------------------------------------------------------------------- */
.phonefield { display: flex; align-items: stretch; }

.phonefield select {
  flex: 0 0 auto;
  width: 7.4em;                 /* "+852 HK" is the widest option there is */
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
  background-color: var(--soft);
  color: var(--ink-2);
  font-variant-numeric: tabular-nums;
}

.phonefield input {
  flex: 1 1 auto;
  min-width: 0;                 /* or a long placeholder stretches the row */
  margin-left: -1px;            /* the two borders become one divider */
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
}

/* --- Share ----------------------------------------------------------------
   Two quiet buttons under the title. They are a way out of the page, not an
   action on it, so neither is filled — the filled button on an event page is
   購票 and there should only ever be one of those. */
.sharebar {
  display: flex; gap: 8px; flex-wrap: wrap;
  margin: 0 0 18px;
}
.sharebar .btn { gap: 7px; }
.sharebar svg { width: 17px; height: 17px; flex: 0 0 auto; }

/* The two seconds after the link is copied, on a browser with no share sheet.
   Without this the button appears to have done nothing at all. */
.sharebar .btn.is-done {
  border-color: var(--accent);
  color: var(--accent);
}

/* --- Event tickets: the people coming with you -----------------------------
   The block only exists from the second ticket onward, so it is introduced
   rather than just appearing: a heading, the promise, then a numbered row per
   guest. The number down the left is what ties a row to the seat it is for and
   to the error message when a number is missing from one of them. */
.guests {
  margin-top: 18px; padding: 16px;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--soft);
}
.guests[hidden] { display: none; }
.guests__lead { margin: 0 0 10px; font-weight: 600; }

.guestrow {
  display: flex; align-items: center; gap: 8px;
  margin-bottom: 8px;
}
.guestrow[hidden] { display: none; }
.guestrow__n {
  flex: 0 0 1.4em; text-align: right;
  color: var(--muted); font-size: .9rem;
}
.guestrow > input[type="text"] { flex: 1 1 40%; min-width: 0; }
.guestrow__phone { flex: 1 1 55%; min-width: 0; }

/* Two fields plus a dialling code will not sit on one line at 390px, and
   squeezing them makes a name field four characters wide. */
@media (max-width: 559px) {
  .guestrow { flex-wrap: wrap; }
  .guestrow__n { flex-basis: 100%; text-align: left; }
  .guestrow > input[type="text"],
  .guestrow__phone { flex: 1 1 100%; }
}

/* The ring belongs to the pair, not to whichever half has focus. */
.phonefield:focus-within select,
.phonefield:focus-within input {
  border-color: var(--accent);
  outline: none;
}
.phonefield:focus-within { border-radius: var(--radius-sm); }
.phonefield select:focus, .phonefield input:focus { position: relative; z-index: 1; }

@media (max-width: 419px) {
  .phonefield select { width: 6.8em; }
}

/* --- Payment screen --------------------------------------------------------
   The one number on this page that matters is the amount, so it is the only
   thing set large. Everything else is context for it.
   ------------------------------------------------------------------------- */
.paysum { display: flex; flex-direction: column; gap: 4px; }
.paysum__what { color: var(--ink-2); }
.paysum__ref { color: var(--muted); font-size: 0.875rem; letter-spacing: .04em; }
.paysum__amount {
  font-size: 2rem; font-weight: 600; color: var(--amber);
  font-variant-numeric: tabular-nums; margin-top: 6px;
}

.payqr { display: flex; gap: 18px; align-items: flex-start; flex-wrap: wrap; }
.payqr img {
  width: 200px; height: 200px; object-fit: contain;
  /* A QR is read by a camera, and a camera wants a light ground whatever the
     rest of the page is doing. */
  background: #fff; padding: 10px; border-radius: var(--radius-sm);
  border: 1px solid var(--line);
}
.payqr__side { flex: 1 1 180px; }
.payqr__amount { font-size: 1.4rem; font-weight: 600; color: var(--amber); }

@media (max-width: 460px) {
  .payqr { justify-content: center; }
  .payqr__side { flex-basis: 100%; }
}

/* ---------------------------------------------------------------------------
   Today

   The screen the shop stands in front of all day. Everything on it is either a
   number to glance at or a thing to press; nothing on it is a report.
   --------------------------------------------------------------------------- */
.todayhead {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: 16px; flex-wrap: wrap; margin-bottom: 18px;
}
.todayhead__actions { display: flex; gap: 8px; flex-wrap: wrap; }

.sectionhead {
  font-family: var(--sans); font-size: 0.875rem; font-weight: 600;
  letter-spacing: .1em; text-transform: uppercase;
  color: var(--muted); margin: 26px 0 10px;
}

/* --- The five numbers ----------------------------------------------------- */
.tiles {
  display: grid; gap: 10px; margin-bottom: 4px;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.tile {
  display: flex; flex-direction: column; gap: 2px;
  padding: 14px 16px;
  border: 1px solid var(--line-2); border-radius: var(--radius);
  background: var(--card); color: inherit; text-decoration: none;
}
a.tile:hover { border-color: var(--accent); }
.tile__value {
  font-family: var(--serif); font-size: 1.7rem; font-weight: 600; line-height: 1.15;
  font-variant-numeric: tabular-nums;
}
.tile__label { color: var(--muted); font-size: 0.875rem; }
.tile--money .tile__value { color: var(--amber); font-family: var(--mono); font-size: 1.4rem; }
.tile--warn  .tile__value { color: var(--amber); }

/* --- What a guest wrote --------------------------------------------------- */
.notecard {
  border: 1px solid var(--line); border-left: 3px solid var(--amber);
  border-radius: var(--radius); background: var(--card);
  padding: 14px 16px; margin-bottom: 10px;
}
.notecard__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.notecard__name { font-weight: 600; text-decoration: none; }
.notecard__said {
  margin: 10px 0 0; padding: 0; border: 0;
  color: var(--ink-2); font-size: .95rem; line-height: 1.6;
}

/* --- A sitting and its guests --------------------------------------------- */
.sitting {
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--card); margin-bottom: 12px; overflow: hidden;
}
.sitting--blocked { opacity: .6; }
.sitting__head {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--app);
}
.sitting__time { font-size: 1.05rem; font-weight: 500; }
.sitting__empty { padding: 12px 16px; margin: 0; }
/* Guests carry their own margin, so the sitting stops adding padding of its
   own and the two do not compound into a channel of empty space. */
.sitting__guests { padding: 2px 6px; }

/* One guest, one card — and a finished one, one line.
   ---------------------------------------------------------------------------
   Two problems, reported by Gan on the same screen.

   **Reading the card took a second look.** Each guest was six things stacked
   downwards — name, meta, what they wrote, a label, a full-width input, a row
   of buttons — so finding out *whose* row this was meant reading down through
   it. The identifying line is now one line: name, party size and reference
   beside it, state at the far end where the eye finishes.

   **A paid table still looked like work.** A sitting that had settled was drawn
   as a full working card, empty note box and loud 结账 button included, sitting
   above one that had not paid. Two rows that look the same do not mean the same
   thing, and the one still owing money is the one that should stand out. Paid
   rows collapse to a line.

   The note is folded away because it is empty on almost every booking, and an
   empty input is not information — it is a form waiting for somebody who has
   not decided to write anything. */
.guest {
  padding: 14px 16px;
  margin: 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--app);
}
.guest + .guest { margin-top: 12px; }

.guest__top {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 10px; flex-wrap: wrap;
}
.guest__who { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; min-width: 0; }
.guest__name {
  font-weight: 600; font-size: 1.14rem; letter-spacing: .01em;
  text-decoration: none; color: var(--ink);
}
.guest__name:hover { color: var(--accent-hi); text-decoration: underline; }
/* Beside the name, not under it: it is the same fact about the same booking. */
.guest__meta { font-size: 0.875rem; color: var(--muted); }

/* Settled. Quiet on purpose — it is a record, not a thing to do. */
.guest--done { background: transparent; border-style: dashed; padding: 11px 16px; }
.guest--done .guest__name { font-weight: 500; font-size: 1rem; color: var(--ink-2); }
.guest__settled {
  display: inline-block; margin-top: 6px;
  font-size: 0.875rem; color: var(--muted); text-decoration: none;
}
.guest__settled:hover { color: var(--accent-hi); text-decoration: underline; }

.guest__said {
  margin: 10px 0 0; padding: 10px 12px; border: 0;
  border-left: 3px solid var(--amber);
  background: var(--amber-2); border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  font-size: .93rem; line-height: 1.6;
}
.guest__said .small { display: block; margin-bottom: 2px; }

/* A picker that fills the screen.
   ---------------------------------------------------------------------------
   The add-a-line sheet is a list to scan, so it gets the height a list needs
   rather than the height its contents happen to be. The shelf scrolls inside
   it; the head and the fold stay put. */
.sheet--tall { max-height: 88vh; }
.sheet--tall .sheet__body { max-height: 70vh; overflow-y: auto; }

/* 茶席 is the line every bill starts with, so it does not look like a tea. */
.tile--session {
  border-style: dashed;
  border-color: var(--accent);
  color: var(--accent-hi);
}
.tile--session .tn { color: var(--accent-hi); }

/* A heading inside the picker.
   ---------------------------------------------------------------------------
   The picker holds three different things now — the sitting, the tea room's
   own 茶单, and the shelf of tins — and forty tiles in one undifferentiated
   grid is a wall. Full width, quiet, and small: it separates without
   competing with the things it separates. */
.tiles__head {
  grid-column: 1 / -1;
  font-size: 0.875rem; letter-spacing: .09em; text-transform: uppercase;
  color: var(--muted); margin: 12px 0 -3px;
}
.tiles__head:first-child { margin-top: 0; }

/* A category heading on the 茶单 screen.
   ---------------------------------------------------------------------------
   The name is a button because the category is a thing you open — rename it,
   switch it off for the season, delete it — and the arrows beside it move the
   whole set rather than one tea. */
.cathead {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; flex-wrap: wrap; margin: 26px 0 8px;
}
.cathead__name { padding: 0; font-size: 1.05rem; font-weight: 600; }
.card--off { opacity: .55; }
.empty--tight { padding: 14px 16px; font-size: 0.875rem; }
.tiles__head .sub { text-transform: none; letter-spacing: 0; opacity: .7; }

/* A quantity, changed where it is read.
   ---------------------------------------------------------------------------
   The checkout's item list used to be read-only, and the only way to change
   your mind was to go back to the cart — throwing away everything already
   typed on the page. These are ordinary submit buttons carrying a value, so
   they work with no JavaScript and cannot get out of step with the basket:
   the number shown is the number the server just wrote. */
.qtyline { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.qtyline__n { min-width: 2ch; text-align: center; font-size: .9rem; }
.qtyline__rm { margin-left: 6px; font-size: 0.875rem; }

.qtystep {
  /* 30px, and 40 on a phone. Both under the floor; both raised. */
  width: 44px; height: 44px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--line-2); border-radius: 8px;
  background: transparent; color: var(--ink-2);
  font: inherit; font-size: 1rem; line-height: 1;
  cursor: pointer;
  transition: border-color .14s ease, color .14s ease;
}
.qtystep:hover { border-color: var(--accent); color: var(--accent-hi); }
.qtystep:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The phone-only bump this used to need is gone: 44px is now the size at every
   width, because a thumb is a thumb on a tablet too. */

/* The paste-an-address box. Set apart from the fields it fills so it reads as
   a shortcut rather than as another thing to fill in. */
.pastebox {
  border: 1px dashed var(--line-2); border-radius: var(--radius-sm);
  padding: 12px 14px; margin-bottom: 16px; background: var(--soft);
}
.pastebox textarea { resize: vertical; }
.pastebox__row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 8px; }

.guest__note { margin: 10px 0 0; }
.guest__note > summary {
  cursor: pointer; list-style: none;
  font-size: 0.875rem; padding: 4px 0;
  color: var(--ink-2);
}
.guest__note > summary::-webkit-details-marker { display: none; }
/* A fold needs to look like one. The mark turns when it opens. */
.guest__note > summary::before {
  content: "＋"; color: var(--muted); margin-right: 6px; font-size: 0.875rem;
}
.guest__note[open] > summary::before { content: "−"; }
.guest__note > summary:hover { color: var(--ink); }
.guest__notetext { color: var(--ink-2); }
.guest__note label { display: block; margin: 6px 0 4px; }
.guest__noterow { display: flex; gap: 8px; }
.guest__noterow input { flex: 1 1 auto; min-width: 0; }

.guest__do {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  margin-top: 12px;
}
.guest__do form { margin: 0; }

/* The status pill and the buttons beside it were three different heights, so a
   row of three controls read as three unrelated things. The pill is not a
   control and keeps its smaller type; it just sits on the same line as the
   things that are. */
/* 44px comes from the hit-area block; this rule used to write it back down
   to 36 and it comes after. */
.guest__do .btn { min-height: 44px; }
.guest__do > .pill { display: inline-flex; align-items: center; min-height: 44px; }

/* The cancel drifts to the far end of the row, as far from 已到訪 and 開帳單
   as the row allows. It is the only control here that cannot be undone, and
   the two beside it are pressed all day. */
/* Scoped, because `.guest__do form { margin: 0 }` above is the more specific
   selector and would otherwise win. */
.guest__do .guest__cancel { margin-left: auto; }

/* --- Waiting to be dealt with --------------------------------------------- */
.waitcard {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
  padding: 14px 16px; margin-bottom: 8px;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--card); color: inherit; text-decoration: none;
}
.waitcard:hover { border-color: var(--accent); }
.waitcard__main {
  display: flex; flex-direction: column; gap: 2px; min-width: 0;
  color: inherit; text-decoration: none;
}
.waitcard__main:hover .waitcard__name { text-decoration: underline; }
.waitcard__ref { color: var(--accent); font-size: .9rem; }
.waitcard__name { font-weight: 600; }
.waitcard__side { display: flex; align-items: center; gap: 12px; }
.waitcard__amount { font-size: 1.05rem; font-weight: 600; color: var(--amber); }

@media (max-width: 559px) {
  .todayhead__actions { width: 100%; }
  .todayhead__actions .btn { flex: 1 1 auto; justify-content: center; }
  .tile__value { font-size: 1.45rem; }
  .waitcard__side { width: 100%; justify-content: space-between; }
}

/* --- Destructive controls --------------------------------------------------
   Cancelling a booking, voiding a bill, deleting a rule. These read as red only
   on hover and focus: a row with a permanently red button in it draws the eye
   to the one thing nobody should press by accident.
   ------------------------------------------------------------------------- */
/* **Danger has to be visible without a hover.** This was `color: var(--muted)`
   and nothing else, turning red only on :hover — which does not exist on the
   phone the counter actually runs on. 作废 sat in a card as grey text with no
   border, indistinguishable from a caption, 40px under the ghost 结账. It now
   rests in red on a red hairline and fills on hover: still the quietest weight
   in any row, but unmistakably a different kind of thing. */
.btn--danger {
  color: var(--red);
  border: 1px solid var(--red);
  background: transparent;
}
.btn--danger:hover,
.btn--danger:focus-visible { color: var(--red); background: var(--red-2); border-color: var(--red); }

/* **The one that wipes the database was painted as the primary action.**
   `.btn--danger` only ever set a colour, so `class="btn btn--danger"` inherited
   the solid accent background from `.btn` — the exact green of 加入购物车 and
   收款 — with a muted-grey label on it, measured at **1.28:1**. The most
   irreversible control in the product looked exactly like the most common one,
   and you could barely read what it said. Solid red, dark text: 7.35:1 dark,
   6.75:1 light. */
.btn--solid-danger {
  background: var(--red);
  border-color: var(--red);
  color: var(--on-accent);
  font-weight: 600;
}
.btn--solid-danger:hover,
.btn--solid-danger:focus-visible {
  background: var(--red); border-color: var(--red);
  color: var(--on-accent); filter: brightness(1.08);
}

/* **One focus ring, ours.**
   There was no global :focus-visible rule — only inputs and three components —
   so every button, link, tab, calendar day and product card fell back to
   Chrome's own outline: a different shape and colour on every control, and
   nearly invisible against the dark ground on some. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: inherit;
}

/* ---------------------------------------------------------------------------
   A panel over the page
   ---------------------------------------------------------------------------
   For editing one thing out of a list without leaving the list. Native
   <dialog>, so Escape closes it and focus is trapped without any of that
   being written by hand — the parts hand-rolled overlays always get wrong.

   The height is capped and the middle scrolls, so a long form does not push
   its own save button off the bottom of a phone. */

dialog.sheet {
  width: min(760px, calc(100vw - 24px));
  max-height: min(86vh, 900px);
  padding: 0;
  border: 1px solid var(--line-2);
  border-radius: var(--radius);
  background: var(--card);
  color: var(--ink);
  box-shadow: var(--sh);
  overflow: hidden;
}

dialog.sheet::backdrop {
  background: rgba(8, 14, 11, .58);
}

.sheet__form {
  display: flex;
  flex-direction: column;
  max-height: inherit;
  margin: 0;
}

.sheet__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 14px;
  padding: 18px 20px 14px;
  border-bottom: 1px solid var(--line);
}

.sheet__head .btn--link {
  font-size: 1.4rem;
  line-height: 1;
  padding: 0 4px;
}

/* The only part that scrolls. `min-height: 0` because a flex child will
   otherwise refuse to shrink below its content and the cap above does
   nothing. */
.sheet__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 16px 20px 20px;
}

.sheet__foot {
  display: flex;
  gap: 10px;
  align-items: center;
  padding: 14px 20px;
  border-top: 1px solid var(--line);
  background: var(--soft);
}

/* A list of things you click a row of. */
table.rows { width: 100%; border-collapse: collapse; }
table.rows td { padding: 13px 14px; border-top: 1px solid var(--line); vertical-align: middle; }
table.rows tr:first-child td { border-top: 0; }
table.rows tr:hover td { background: var(--soft); }

.btn--sm { padding: 6px 12px; font-size: 0.875rem; }

/* ---------------------------------------------------------------------------
   A switch
   ---------------------------------------------------------------------------
   Reads as a state rather than an instruction. The old control was a button
   saying 关闭 sitting beside a pill saying 已关闭 — you could not tell which
   was the state and which was the action.

   It is a <button> in a form, not a checkbox: it submits by itself, so it
   works with JavaScript off. The knob sits on the side that matches the
   state, and the colour carries the same information a second time for
   anyone who cannot see the position clearly. */

.switch {
  --sw-h: 30px;
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: var(--sw-h);
  padding: 0 12px 0 10px;
  border: 1px solid var(--line-2);
  border-radius: 999px;
  background: var(--soft);
  color: var(--muted);
  font: inherit;
  font-size: 0.875rem;
  letter-spacing: .06em;
  cursor: pointer;
  transition: background .16s ease, border-color .16s ease, color .16s ease;
}

/* Knob first when off, last when on — so the thing physically moves across
   rather than only changing colour. */
.switch__knob {
  order: -1;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--muted);
  flex: 0 0 auto;
  transition: background .16s ease;
}

.switch.is-on {
  background: var(--accent-2);
  border-color: var(--accent);
  color: var(--accent-hi);
  padding: 0 10px 0 12px;
}

.switch.is-on .switch__knob {
  order: 1;
  background: var(--accent);
}

.switch:hover  { border-color: var(--line-strong); }
.switch:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.switch__label { line-height: 1; }

/* The channel chips — WhatsApp and 电邮, per message.
   ---------------------------------------------------------------------------
   Deliberately *not* another .switch. Three switches in a row all drawn the
   same way would read as three equal choices, and they are not: 停用 is the
   master above, and these two are the roads beneath it. So they are smaller,
   they wear their own name rather than 开/关, and the state is a lit dot.

   No amber anywhere near them. Amber is money. */

.chipswitch {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 44px;
  padding: 0 14px;
  border: 1px solid var(--line-2);
  border-radius: 999px;
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: 0.875rem;
  letter-spacing: .04em;
  cursor: pointer;
  white-space: nowrap;
  transition: background .16s ease, border-color .16s ease, color .16s ease;
}

.chipswitch__dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--line-strong);
  flex: 0 0 auto;
  transition: background .16s ease, box-shadow .16s ease;
}

.chipswitch.is-on {
  background: var(--accent-2);
  border-color: var(--accent);
  color: var(--accent-hi);
}

.chipswitch.is-on .chipswitch__dot {
  background: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}

/* The master switch is off, so neither road matters. The chips keep their own
   state — turning the message back on must not have silently rewritten it —
   but they stop competing for attention with the thing that is actually
   stopping the message. */
.chipswitch.is-muted { opacity: .45; }

.chipswitch:hover { border-color: var(--line-strong); }
.chipswitch:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ---------------------------------------------------------------------------
   Roundness
   ---------------------------------------------------------------------------
   Google Sans Flex carries a ROND axis from 0 to 100, so the softness of the
   Latin is a setting rather than a different typeface. 70 rather than 100:
   fully round reads as a children's book, and this is a shop that also has to
   print a receipt.

   Guarded, because a browser without variable-font support would otherwise
   apply nothing and lose the weight along with it. */
@supports (font-variation-settings: normal) {
  body { font-variation-settings: "ROND" 70; }
}

/* --- The brand ------------------------------------------------------------
   The one place the serif is still used, and the reason it survives at all:
   老葉的児子 is written with 葉 and the variant 児, and the rounded face has
   neither. Set in the body face, those two characters would fall to a
   fallback and the shop's own name would render in two typefaces at once. */
.brand__name, .site-footer strong, .receipt__brand {
  font-family: var(--serif);
  font-weight: 500;
  letter-spacing: .1em;
}

/* ---------------------------------------------------------------------------
   The slideshow
   ---------------------------------------------------------------------------
   The sliding is scroll-snap, so a swipe on a phone is the browser's own and
   the whole thing still works with the script gone — it degrades to a
   scrollable row of photographs rather than a dead box.

   The arrows and dots sit on top of that and are hidden from a pointerless
   device, where the swipe is better than either. */

/* --- The one gallery ------------------------------------------------------
   **The shape is the module's, not the photograph's.** One fixed crop per
   place, so the customer sees one shape per place and the page does not change
   height depending on how somebody happened to hold their phone.

   These four numbers are the whole of it. Change one here and the gallery, the
   viewport cap and the admin slots all follow, because they read the same
   custom property. `Photos::SHAPES` documents the same table on the PHP side
   and tests/gallery.php holds the two together.

   Events are 4:5 rather than 2:3 on purpose: a poster is designed at an
   Instagram post's proportions and the words are part of the picture, so 4:5
   drops one in untouched where 2:3 would take about a sixth off each side. */
.slides--product,
.slides--variant { --slides-ar: calc(1 / 1); }
.slides--event   { --slides-ar: calc(4 / 5); }
.slides--room,
.slides--space   { --slides-ar: calc(3 / 2); }

/* A tall crop is capped by the height of the screen, not by cutting more off.
   A 2:3 tea in a 558px-wide column is 837px tall, which on a laptop puts the
   price and 加入购物车 below the fold. The answer is not a second crop but a
   narrower box: same shape, same crop, centred in the space it gave up.
   --slides-ar multiplies a viewport height directly. A landscape gallery never
   reaches the cap, and neither does a phone, where the width runs out first. */
.slides {
  position: relative; margin: 0 auto 26px;
  max-width: min(100%, calc(58vh * var(--slides-ar, calc(3 / 2))));
}

.slides__track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
  gap: 0;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  border-radius: var(--radius);
  background: var(--soft);
  -webkit-overflow-scrolling: touch;
}
.slides__track::-webkit-scrollbar { display: none; }

.slides__one {
  scroll-snap-align: start;
  display: block;
  padding: 0;
  border: 0;
  background: none;
  cursor: zoom-in;
  width: 100%;
}

/* The crop itself. `cover`, because the whole point is that everything in one
   place is one shape — a photograph that arrives at a different shape gives up
   its edges rather than the layout giving up its rhythm.

   The crop is only ever the thumbnail. Tapping opens the original whole and
   uncropped, which is why the cursor says zoom-in and why cropping this hard is
   safe. The back office marks a photograph the crop takes a real bite out of
   before it ever reaches a customer — see Photos::cropsHard(). */
.slides__one img {
  display: block;
  width: 100%;
  aspect-ratio: var(--slides-ar, calc(3 / 2));
  object-fit: cover;
  object-position: center;
  border-radius: var(--radius);
  background: var(--soft);
}

.slides__arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 44px; height: 44px;
  display: none;
  align-items: center; justify-content: center;
  border: 1px solid var(--line-2);
  border-radius: 50%;
  background: var(--card);
  color: var(--ink);
  font-size: 1.3rem; line-height: 1;
  cursor: pointer;
  box-shadow: var(--sh);
}
.slides__arrow--prev { left: 10px; }
.slides__arrow--next { right: 10px; }
.slides__arrow:disabled { opacity: .3; cursor: default; }

/* Arrows only where there is a pointer to use them. On a touch screen the
   swipe is better and two buttons over the photograph are just in the way. */
@media (hover: hover) and (pointer: fine) {
  .slides__arrow { display: flex; }
}

.slides__dots {
  display: flex;
  justify-content: center;
  gap: 0;
  margin-top: 6px;
  flex-wrap: wrap;
}

/* **The dot you see and the dot you press are not the same size.**

   A 7px dot is far too small to hit with a thumb, so the touch-target rule
   further up grew it to 24 × 40 — and with `border-radius: 50%` on the element
   itself, that drew a 24 × 40 *oval*. Three of them, the size of thumbnails,
   under every gallery.

   The button is the target and stays 24 × 40; the mark is a pseudo-element and
   stays 7px. Which is the rule generally: make the hit area big, not the thing. */
.slides__dot {
  width: 24px; height: 34px;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  display: grid;
  place-items: center;
  cursor: pointer;
}
.slides__dot::before {
  content: '';
  width: 7px; height: 7px;
  border-radius: 50%;
  background: var(--line-2);
  transition: background .15s ease, transform .15s ease;
}
.slides__dot.is-on::before { background: var(--accent); transform: scale(1.35); }

/* One photograph, full size. */
dialog.lightbox {
  padding: 0;
  border: 0;
  background: transparent;
  max-width: 96vw;
  max-height: 92vh;
  overflow: visible;
}
dialog.lightbox::backdrop { background: rgba(6, 11, 8, .88); }
/* The uncropped original, whatever shape it is. No aspect-ratio and no cover
   here on purpose — the square in the gallery is a thumbnail, and the reason
   for tapping it is to see what the crop took off. */
dialog.lightbox img {
  display: block;
  width: auto;
  height: auto;
  max-width: 96vw;
  max-height: 92vh;
  object-fit: contain;
  border-radius: var(--radius);
}
.lightbox__close {
  position: absolute;
  /* Inside the dialog, not outside it: at -14px on a 96vw dialog the button's
     right edge landed 7px off a 320px screen. */
  top: 6px; right: 6px;
  width: 44px; height: 44px;
  border: 1px solid var(--line-2);
  border-radius: 50%;
  background: var(--card);
  color: var(--ink);
  font-size: 1.1rem; line-height: 1;
  cursor: pointer;
}

@media (prefers-reduced-motion: reduce) {
  .slides__track { scroll-behavior: auto; }
  .slides__dot::before { transition: none; }
}

/* The checklist. Two columns where there is room, because eleven ticks down a
   single column reads as a form to endure rather than a menu to pick from. */
.checkgrid { display: grid; gap: 2px; }

@media (min-width: 560px) {
  .checkgrid { grid-template-columns: 1fr 1fr; column-gap: 18px; }
}

/* Fifteen photographs want smaller tiles than four do — at the four-slot size
   the space-hire page becomes a very long scroll of thumbnails. */
/* How many across, decided by the controls under each picture.
   ---------------------------------------------------------------------------
   ‹ › × is 136px wide at the 44px floor. A tile narrower than that wraps them
   to two rows and then three, and at 87.6px — which is what five across gave on
   a 560px tablet — the row of buttons was 140px tall over a 57px photograph.
   So each step up in columns waits until the tiles clear 136px:

     under 560   two across    242px at 559
     560–899     three         158px at 560
     900–1199    five          159px at 900
     1200 and up six           161px at the 1080px cap  */
.gallery--many { grid-template-columns: repeat(2, 1fr); gap: 10px; }
@media (min-width: 560px)  { .gallery--many { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 900px)  { .gallery--many { grid-template-columns: repeat(5, 1fr); } }
@media (min-width: 1200px) { .gallery--many { grid-template-columns: repeat(6, 1fr); } }

/* --- 规格, stock and the save bar (phase 14) ------------------------------- */

/* A hairline between two sections of one card. The product page now holds
   fields, sizes, photographs and a ledger in a single box, and without these
   it reads as one undifferentiated wall. */
.rowsep {
  height: 1px; background: var(--line);
  margin: 22px -20px;
}

/* The sizes table. Inputs sit flush in the cells — a table of bordered boxes
   inside bordered cells is two grids fighting. */
.vartable td { padding: 6px 8px; vertical-align: middle; }
.vartable input[type="text"],
.vartable input[type="number"] {
  height: 44px; padding: 4px 8px; margin: 0;
  font-size: .9rem; min-width: 0; width: 100%;
}
.vartable input.num { text-align: right; font-variant-numeric: tabular-nums; }
.vartable th { white-space: nowrap; }
.vartable__stock { white-space: nowrap; }
.vartable__stock .btn--link { margin-left: 8px; font-size: 0.875rem; }
/* The blank row is an invitation, not a record. It reads quieter until used. */
.vartable__new input { opacity: .75; }
.vartable__new input:focus { opacity: 1; }

/* Stock at or under its threshold. Amber rather than red: low is a thing to
   notice on the morning round, not an emergency. */
.stock--low { color: var(--amber); font-weight: 500; }
.stock--out { color: var(--red); font-weight: 500; }

.money--out { color: var(--red); }
.money--in  { color: var(--accent); }

/* The destructive control. Deliberately not a red panel — a tea house does not
   need a danger zone, it needs the irreversible thing to be somewhere a thumb
   does not land by accident, and to say what it will do before it does it. */
.dangerline {
  display: flex; align-items: center; justify-content: space-between;
  gap: 16px; flex-wrap: wrap;
  margin: 28px 0 40px;
  padding-top: 18px;
  border-top: 1px solid var(--line);
}

/* --- The save bar ---------------------------------------------------------
   Appears at the bottom of the window the moment something in the form changes,
   and only then. On a long product page you should never scroll hunting for
   Save, and you should not be able to walk away from unsaved edits without the
   page saying so.

   It sits above the admin tab bar rather than over it, which is why the offset
   is a token and not a guess.
   ------------------------------------------------------------------------- */
.savebar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
  display: flex; align-items: center; justify-content: flex-end;
  gap: 12px;
  padding: 12px 20px;
  padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
  background: var(--card);
  border-top: 1px solid var(--line-2);
  box-shadow: 0 -4px 20px rgba(23,32,27,.14);
  transform: translateY(110%);
  transition: transform .18s ease;
}
.savebar__note { margin-right: auto; color: var(--amber); font-size: .9rem; }
.savebar.is-on { transform: translateY(0); }

body:has(.tabbar) .savebar { bottom: calc(var(--tabh) + env(safe-area-inset-bottom, 0px)); }

@media (min-width: 760px) {
  /* The bar is hidden up here, so the save bar goes back to the floor. */
  body:has(.tabbar) .savebar { bottom: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .savebar { transition: none; }
}

@media print {
  .savebar, .dangerline { display: none !important; }
}

/* --- The size chooser -----------------------------------------------------
   Links, not radio buttons and not a script. Each one carries its own price,
   because without JavaScript the headline price cannot follow a selection —
   and putting the price on the option is clearer anyway: the customer can
   compare the sizes without clicking any of them.

   A sold-out size stays on screen, struck through. Somebody who came for the
   250g needs to be told it has run out; removing it looks like it was never
   sold here.
   ------------------------------------------------------------------------- */
.sizes { margin: 4px 0 18px; }
.sizes__label {
  display: block; margin-bottom: 8px;
  font-size: 0.875rem; color: var(--muted);
  letter-spacing: var(--track-head);
}
.sizes__row { display: flex; flex-wrap: wrap; gap: 8px; }

.sizechip {
  display: flex; flex-direction: column; gap: 2px;
  min-width: 92px;
  padding: 9px 14px;
  border: 1px solid var(--line-2);
  border-radius: var(--radius-sm);
  background: var(--card);
  color: var(--ink-2);
  text-decoration: none;
  line-height: 1.25;
}
.sizechip:hover { border-color: var(--accent); color: var(--ink); }
.sizechip__name  { font-size: .92rem; }
.sizechip__price { font-size: 0.875rem; color: var(--muted); }
.sizechip__out   { font-size: 0.875rem; color: var(--red); }

.sizechip.is-on {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
  color: var(--ink);
}
.sizechip.is-on .sizechip__price { color: var(--accent); }

.sizechip.is-out { opacity: .55; }
.sizechip.is-out .sizechip__name { text-decoration: line-through; }

/* --- The translate button -------------------------------------------------
   Hidden until app.js confirms it is running. A button that does nothing is
   worse than no button, and this one is a convenience rather than a step —
   every field it fills can still be typed.

   Quiet on purpose: it sits beside a label, and it is not the thing on the
   page you are meant to look at.
   ------------------------------------------------------------------------- */
.aitr { display: none; }

.js .aitr {
  display: inline-flex; align-items: center;
  margin-left: 8px;
  padding: 2px 9px;
  border: 1px solid var(--line-2);
  border-radius: 999px;
  background: transparent;
  color: var(--muted);
  font-family: inherit; font-size: 0.875rem;
  letter-spacing: var(--track-cjk);
  cursor: pointer;
}
.js .aitr:hover:not(:disabled) { color: var(--accent-hi); border-color: var(--accent); }
.js .aitr:disabled { opacity: .55; cursor: default; }

.aitr__note {
  display: inline-block;
  margin-left: 8px;
  color: var(--red);
  font-size: 0.875rem;
}

/* The add-a-row button. Hidden without JavaScript, where the single blank row
   in the table is still there and still works — one size per save. */
.addrow { display: none; }
.js .addrow { display: inline-flex; font-size: 0.875rem; padding: 6px 14px; }

/* Day, month, year — three boxes rather than the browser's calendar, because
   the calendar's order comes from the operator's preferred language and no
   amount of asking politely moves it. See views/partials/datefield.php.

   The month box is the widest: it holds a name, and a name is the whole point.
   The note that used to live here, explaining how to change a macOS setting,
   is gone with the control that needed it. */
.dparts { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.dparts select,
.dparts input {
  height: var(--control-h, 40px);
  padding: 6px 8px;
  margin: 0;
  font-size: .95rem;
  min-width: 0;          /* or a flex item refuses to shrink below its content */
}

/* They share the row and give way in proportion, rather than each holding a
   fixed width and pushing the year onto a second line. The month is widest
   because it holds a name, and the name is the entire point of the control. */
.dparts__d { flex: 1 1 62px;  max-width: 96px; }
.dparts__m { flex: 2 1 92px;  max-width: 150px; }
.dparts__y { flex: 1 1 76px;  max-width: 110px; }
.dparts__t { flex: 1 1 92px;  max-width: 130px; }

/* A date needs about three times the room of a text box, so a column sized for
   one leaves it stacked three high. */
.field-row .field:has(.dparts) { flex: 1 1 300px; }

/* --- The picker -----------------------------------------------------------
   Drawn over the three selects by app.js, which keeps writing into them. The
   selects are the truth and this is a nicer way to reach them; if the script
   never runs, they are simply visible and the form works exactly as before.
   ------------------------------------------------------------------------- */
.dparts.is-picked { display: none; }

.dpick__chips { display: flex; gap: 8px; flex-wrap: wrap; }
.dpick__chip {
  font: inherit; font-size: .95rem; letter-spacing: var(--track);
  background: var(--soft); color: var(--ink);
  border: 1px solid transparent; border-radius: 10px;
  padding: 7px 13px; cursor: pointer; white-space: nowrap;
  transition: background .12s ease, color .12s ease, border-color .12s ease;
}
.dpick__chip:hover { border-color: var(--line-2); }
.dpick__chip.is-empty { color: var(--muted); }
.dpick__chip--time { font-family: var(--mono); font-variant-numeric: tabular-nums; }

/* 23/08/2026 星期日. The numbers are the value, so they are set in the same
   figures as the clock chip beside them and line up with it. The weekday is
   what the value *means* — true, useful, and not something you are choosing —
   so it is set quieter and a shade smaller, and it stays that way when the chip
   is open and the colours invert. */
.dpick__chip--date { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.dpick__dowlbl {
  font-family: var(--sans); font-size: .875rem; letter-spacing: var(--track);
  color: var(--ink-2); margin-left: 7px;
}
.dpick__chip.is-open .dpick__dowlbl { color: inherit; opacity: .75; }
/* Open reads as "you are editing this one", which is what stops the two panels
   feeling like two unrelated controls that happen to sit together. */
.dpick__chip.is-open {
  background: var(--accent-2); color: var(--accent-hi);
  border-color: var(--accent);
}

/* **A calendar, not a wall.**

   Every day cell is a square, which is right in a phone-width field and absurd
   in a wide admin card: seven columns across 1,900px made each cell 270px tall,
   and Gan's screenshot of it was five screens of empty circles. A calendar has
   a natural size and this is it — the panel stops growing, and the days stay
   the size of a thumb. */
/* 380, not 330. Seven day cells at the 44px floor plus their gutters need 320
   of content width, and the panel's own padding takes the rest; at 330 a day
   was 41px on a 1440px screen, where nothing but this number was stopping it.
   Still `min()`-capped against the viewport, so a 320px phone is unaffected —
   there, seven columns of a week genuinely cannot each be 44px. */
.dpick__panel { display: none; padding: 6px 0 10px; max-width: min(380px, calc(100vw - 28px)); }
.dpick__panel.is-open { display: block; animation: dpickdrop .16s ease; }
@keyframes dpickdrop { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }

.dpick__head { display: flex; align-items: center; justify-content: space-between; padding: 10px 2px 12px; }
.dpick__month { font-size: 1rem; font-weight: 500; letter-spacing: var(--track-head); }
.dpick__nav { display: flex; gap: 4px; }
.dpick__nav button {
  /* The month arrows on the date picker — the control a guest uses to find a
     Saturday three weeks out, and the one an operator uses to set a booking. */
  width: 44px; height: 44px; border-radius: 50%; border: 0;
  background: transparent; color: var(--accent);
  font-size: 1.15rem; line-height: 1; cursor: pointer;
}
.dpick__nav button:hover { background: var(--soft); }

.dpick__dow, .dpick__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.dpick__dow span { text-align: center; color: var(--muted); font-size: 0.875rem; padding-bottom: 6px; }
.dpick__grid button {
  aspect-ratio: 1 / 1; border: 0; background: transparent; color: var(--ink);
  font: inherit; font-size: .95rem; font-variant-numeric: tabular-nums;
  border-radius: 50%; cursor: pointer;
}
.dpick__grid button:hover:not(:disabled) { background: var(--soft); }
.dpick__grid button.is-out { color: var(--muted); opacity: .4; }
.dpick__grid button:disabled { opacity: .2; cursor: default; }
/* A ring for today, a filled circle for the day chosen — never both meaning
   the same thing. */
/* **Today is circled.** Gan asked for it, and the pair only works if the two
   states are different *shapes* rather than two weights of the same one:
   today is an outlined circle, the day you have chosen is a filled one. Every
   calendar people already use draws it this way. It was a hairline in
   --line-2 before, which measured about the same as the grid it sat on. */
.dpick__grid button.is-today {
  box-shadow: inset 0 0 0 1.5px var(--accent);
  color: var(--accent);
  font-weight: 500;
}
.dpick__grid button.is-today.is-on { color: var(--on-accent); }
.dpick__grid button.is-on { background: var(--accent); color: var(--on-accent); font-weight: 500; }

/* **The shop's own shape.**

   Gan: 「can the calendar circle my business date?」 — so a day the tea room is
   shut on is drawn quiet, and a one-off opening carries an amber ring.

   Quiet rather than struck through or red: three days of every week are closed
   and a calendar that shouts about half of itself has stopped being readable.
   Amber because the ring means 「something was decided about this day」, which
   is the same thing amber means everywhere else in this application.

   Neither mark disables the day. The counter has to be able to record a
   private hire on a Monday, and a picker that refuses is a picker that gets
   worked around with a screenshot and a phone call. */
.dpick__grid button.is-shut { color: var(--muted); opacity: .55; }
.dpick__grid button.is-shut.is-on { opacity: 1; color: var(--on-accent); }
.dpick__grid button.is-extra { box-shadow: inset 0 0 0 1px var(--amber); }

.dpick__foot { display: flex; justify-content: space-between; padding-top: 10px; }
/* 今天 and 清除 at the foot of the date picker: 36 x 36 before this. */
.dpick__link {
  background: none; border: 0; color: var(--accent);
  font: inherit; font-size: 0.875rem; cursor: pointer; padding: 4px 10px;
  min-height: 44px; min-width: 44px;
  display: inline-flex; align-items: center; justify-content: center;
}
.dpick__link:hover { color: var(--accent-hi); }

/* The wheels. Scroll-snap does the work, exactly as it does in the photo
   gallery — native momentum on a phone, and a control that still scrolls if
   the script that reads it ever fails. */
.dpick__wheelbox { display: flex; justify-content: center; padding: 8px 0 2px; }
.dpick__wheels { position: relative; display: flex; gap: 2px; }
.dpick__wheels::before {
  content: ""; position: absolute; left: -14px; right: -14px; top: 50%;
  height: 44px; transform: translateY(-50%);
  background: var(--soft); border-radius: 9px; pointer-events: none;
}
.dpick__wheel {
  /* Five rows of 44. Was five of 38 — and every one of those rows is a
     tap target for somebody setting a time. */
  height: 220px; overflow-y: auto; scroll-snap-type: y mandatory;
  scrollbar-width: none; -ms-overflow-style: none;
  -webkit-mask-image: linear-gradient(180deg, transparent, #000 26%, #000 74%, transparent);
          mask-image: linear-gradient(180deg, transparent, #000 26%, #000 74%, transparent);
  text-align: center; position: relative; z-index: 1;
}
.dpick__wheel::-webkit-scrollbar { display: none; }
.dpick__wheel > div {
  height: 44px; line-height: 44px; scroll-snap-align: center;
  font-family: var(--mono); font-size: 1.15rem; font-variant-numeric: tabular-nums;
  color: var(--muted); cursor: pointer; transition: color .1s ease;
}
.dpick__wheel > div.is-on { color: var(--ink); font-size: 1.3rem; }
.dpick__wheel--h, .dpick__wheel--m { width: 76px; }
.dpick__wheel--ap { width: 66px; }
.dpick__wheel--ap > div { font-family: var(--sans); font-size: 1rem; letter-spacing: var(--track-head); }
.dpick__wheel .pad { height: 88px; cursor: default; }

@media (prefers-reduced-motion: reduce) {
  .dpick__panel.is-open { animation: none; }
  .dpick__wheel { scroll-behavior: auto; }
}

@media print {
  .dpick__panel, .dpick__chips { display: none !important; }
}

/* The panel a size chooser swaps. A whisper of a fade so the change registers
   as a change rather than a flicker — and nothing that moves the page, because
   the whole point is that the scroll position stays where the reader left it. */
#prodpanel { transition: opacity .12s ease; }
#prodpanel.is-swapping { opacity: .55; }

@media (prefers-reduced-motion: reduce) {
  #prodpanel { transition: none; }
}

/* A hint that is really a warning — a server setting that needs changing, not
   a note about how the screen works. Amber, like every other figure that wants
   attention, and never red: nothing is broken, something is set too low. */
.hint--warn { color: var(--amber); }

/* The second level of 设置.
   ---------------------------------------------------------------------------
   Pills inside the page rather than a third bar in the header: two navigation
   bars are already a third of a phone screen, and this application made the
   same call once before for the event lists. Plain links, so each group has an
   address you can bookmark and an upgrade note can point at. */
.subpills {
  display: flex; flex-wrap: wrap; gap: 6px;
  margin: 14px 0 10px;
}
.subpill {
  /* The only route to six settings pages, and it measured 29.4px tall. */
  display: inline-flex; align-items: center;
  min-height: 44px; padding: 0 16px; border-radius: 999px;
  border: 1px solid var(--line-2); background: transparent;
  color: var(--muted); text-decoration: none;
  font-size: 0.875rem; line-height: 1;
  transition: border-color .14s ease, color .14s ease;
}
.subpill:hover { border-color: var(--accent); color: var(--ink); }
.subpill.on {
  border-color: var(--accent); color: var(--accent-hi);
  background: var(--accent-2);
}
@media (max-width: 759px) { .subpill { padding: 0 18px; } }

/* ---------------------------------------------------------------------------
   The month
   ---------------------------------------------------------------------------
   Gan asked where he could block a sitting on a date other than this week. The
   controls were already there; what was missing was anywhere to *look*. A date
   picker answers a question you can only ask if you already know the date.

   Seven columns, each day a link carrying two facts: whether the room opens,
   and how many sittings if it does. Everything else — a blocked sitting, an
   extra one — is a single amber dot, because a month grid that tries to say
   four things per cell says none of them at a glance. */

.monthnav {
  display: flex; align-items: center; gap: 10px;
  margin-bottom: 10px;
}
.monthnav b { font-size: 1rem; }
.monthnav a.small { margin-left: auto; }

/* Capped, for the same reason the date picker is: a month stretched across a
   1,900px card is seven columns of empty space with a number in the corner. A
   calendar has a natural size. */
.monthgrid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  /* Seven columns and six gaps. At 4px a day cell came to 41px on a 390px
     phone; at 2px it clears 44 with room to spare, and nobody has ever read a
     calendar by its gutters. */
  gap: 2px;
  max-width: 560px;
}
.monthnav { max-width: 560px; }
/* **The one target that cannot reach 44px, and why.**
   ---------------------------------------------------------------------------
   A month is seven columns because a week is seven days. Seven 44px cells and
   six 2px gutters need 320px of content width. The card gives back what it can
   — the padding below — and a cell reaches 44px wide at exactly **382px** of
   viewport: measured, 44.00 at 382, 45.14 at 390 (iPhone 12 through 15), 48.58
   at 414, 66.56 at 560.

   Below 382 the arithmetic runs out and it does not care how it is written:
   43.00px at 375, 40.86 at 360, 35.14 at 320. Height is not the constraint —
   the cell is 50.22px tall below 560 and 68.33 at 560 and above — and the
   whole cell is the link. The alternative, a calendar that scrolls sideways,
   destroys the one thing a month view is for, which is seeing the shape of the
   weeks. WCAG 2.2 2.5.8 allows a target whose size is essential to the
   information it presents; a day of the week is that. */
@media (max-width: 430px) {
  .card:has(.monthgrid) { padding-left: 10px; padding-right: 10px; }
}

/* The same arithmetic, one step down.
   ---------------------------------------------------------------------------
   A card's 20px of side padding costs 40px of the 320px a small Android phone
   has, and a bill line needs 270 of what is left once the quantity steppers are
   real targets. 14px gives it back without anything looking cramped: the text
   still clears the border by more than a line's leading. */
@media (max-width: 400px) {
  .card { padding-left: 14px; padding-right: 14px; }
}

/* The header on a phone.
   ---------------------------------------------------------------------------
   The tools row gained a third pill in 058 — 白天 / 晚上 — and on a 320px
   screen the language switch, the theme switch and 登出 each took a line of
   their own: the sticky header went from 128px to 189px, and with the 76px tab
   bar that is 47% of a 568px screen given over to chrome before a single
   booking is visible.

   The two switches are two characters a side in Chinese and four or five in
   English; at this width they do not need eleven pixels of padding each to
   read as buttons, and the 44px floor is untouched — only the sides move. Two
   rows, not three. */
@media (max-width: 560px) {
  /* And it scrolls away.
     ---------------------------------------------------------------------
     Sticky, the header and the tab bar together held 50% of a 320 x 568
     screen after every scroll — measured at 228.6 + 57.9 on a page with a
     sub-tab row. The tab bar is the navigation and stays pinned; the header
     is a brand, three notices and three switches, and none of those is
     something you reach for mid-scroll. It comes back the moment you scroll
     to the top. */
  .site-header { position: static; }
  .langpill__opt { padding: 0 6px; }
  .site-header__tools { gap: 6px; }
  .site-header__inner { padding-top: 8px; padding-bottom: 8px; }
  .site-header__tools .btn { padding-left: 10px; padding-right: 10px; }
}

/* The way back, on every detail screen in the back office: 「← 账单」,
   「← 预约」, 「← 产品」. It is a paragraph of small muted text with one link
   in it, and the link measured 54 x 20. */
p.small.muted.mb-0 > a { min-height: 44px; display: inline-flex; align-items: center; }
/* The language link on the sign-in screen and on a customer's receipt — the
   bare 「EN」 in a line of small muted type. 21 x 20px, and on the sign-in
   screen it is one of only two controls on the page. */
p.small > a, .site-footer__legal a, .alert a { min-height: 44px; min-width: 44px;
  display: inline-flex; align-items: center; justify-content: center; }
/* The three social icons in the footer sit in a 40px box. */
.social__link { min-width: 44px; min-height: 44px; }
.monthgrid__head {
  font-size: 0.875rem; color: var(--muted); text-align: center;
  padding-bottom: 2px; letter-spacing: .04em;
}
.monthgrid__pad { min-height: 0; }

.mday {
  position: relative;
  display: flex; flex-direction: column; gap: 2px;
  min-height: 44px; padding: 5px 6px;
  border: 1px solid var(--line-2); border-radius: 10px;
  background: var(--card);
  color: var(--ink); text-decoration: none;
  font-size: 0.875rem; line-height: 1.15;
}
.mday:hover { border-color: var(--accent); }
.mday__n { font-family: var(--mono); font-size: 0.875rem; }
.mday__s { color: var(--muted); font-size: 0.875rem; }

/* Closed reads as quieter, not as an error: most weeks have three of them and
   a wall of red would be the loudest thing on the page. */
.mday--off { background: var(--soft); }
.mday--off .mday__n { color: var(--muted); }

.mday--past { opacity: .5; }
/* The cell is a rectangle with two lines in it, so the circle goes round the
   number rather than round the day — a ringed cell would be one more rectangle
   competing with the selected one. */
.mday--today .mday__n {
  /* align-self, or the flex column stretches the circle into an oval — the
     span is a flex child and inline-flex does not stop it filling the row. */
  align-self: flex-start;
  display: inline-flex; align-items: center; justify-content: center;
  /* 26px, not 21. The day number is 15.75px since the type floor went in, and
     a 21px ring with a 1.5px border left 18px of room for two digits — the
     glyphs touched the circle. */
  width: 26px; height: 26px; margin: -2px 0 0 -5px;
  border-radius: 50%; border: 1.5px solid var(--accent);
  color: var(--accent); font-weight: 600; font-size: 0.875rem;
}

/* The day the panel below is showing. Filled rather than outlined, so it is
   still obvious next to today's border. */
.mday--on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }

/* The short spellings exist only where the long ones will not fit. */
.mday__short { display: none; }
.mday__dot {
  position: absolute; top: 5px; right: 7px;
  color: var(--amber); font-size: 1rem; line-height: 1;
}

/* On a phone seven columns are 35–45px wide. The count drops to digits — see
   `.mday__unit` below and the note in views/partials/monthgrid.php — and the
   cell loses its padding, rather than the grid losing a column: a month that
   scrolls sideways is a month nobody reads. */
@media (max-width: 559px) {
  .mday { min-height: 46px; padding: 5px 4px; font-size: 0.875rem; }
  .mday__s { font-size: 0.875rem; }
  /* The long spelling goes, the short one comes. See the note in
     views/partials/monthgrid.php. */
  .mday__unit { display: none; }
  .mday__short { display: inline; }
  /* 2px, agreeing with the base rule — this said 3px, came later, and so
     quietly cancelled the gap change below 560px, which is exactly the range
     the note above the base rule reasons about. */
  .monthgrid { gap: 2px; }
}

/* ---------------------------------------------------------------------------
   The day menu
   ---------------------------------------------------------------------------
   Gan: 「when i right click this date - it pop up... close this date, check my
   slot, block slot in this date」.

   A <dialog> like every other panel in this application, so Escape closes it
   and the focus stays inside. What is different is the shape: this one is a
   list of times you act on, not a form you fill in, so the rows are wide and
   flat and every one of them has its verb at the end of it. */

.daymenu { max-width: 420px; }

.daymenu__slots { display: flex; flex-direction: column; gap: 2px; }

/* time · who is in it · state · the button. The middle two collapse to nothing
   on a sitting that is empty and open, which is most of them — a row that says
   only 「14:00–16:00  封锁」 is the row you can read at a glance. */
.daymenu__slot {
  display: grid;
  grid-template-columns: auto 1fr auto auto;
  align-items: center; gap: 8px;
  padding: 7px 2px;
  border-bottom: 1px solid var(--line);
}
.daymenu__slot:last-child { border-bottom: 0; }

.daymenu__acts {
  display: flex; flex-direction: column; gap: 6px;
  padding: 12px 16px 16px;
  border-top: 1px solid var(--line);
}

/* The day cells are links, and a link that opens a menu should not look like
   it is about to navigate. */
.mday { cursor: pointer; }

/* A voucher the till found for this guest.
   ---------------------------------------------------------------------------
   Value first and in amber, because that is the number being offered; the code
   quiet beside it, since nobody needs to read it — the button posts it. */
.vrow {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 9px 0; border-bottom: 1px solid var(--line);
}
.vrow:last-of-type { border-bottom: 0; }

/* The vouchers a phone number already holds, offered at the checkout. */
.mine { margin: 10px 0 12px; }
.mine__row {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 7px 0; border-bottom: 1px solid var(--line);
}
.mine__row:last-child { border-bottom: 0; }

/* ---------------------------------------------------------------------------
   方案 — the generate flow, and the block editor

   The questions on 第二步 are answered by tapping. Chips where there is a small
   set of answers, a short line where there is not — a question that needs a
   paragraph is a question that should not have been asked.

   **The radio stays visible inside the pill.** `:has(:checked)` tints the whole
   chip, which is what makes it read as a choice rather than a form row — but a
   tint is the *only* thing that rule carries, so on a browser without `:has()`
   the radio itself still shows which one is picked. The one control this screen
   turns on must not depend on a selector.
   ------------------------------------------------------------------------- */
.pills { display: flex; flex-wrap: wrap; gap: 8px; }

.pillopt {
  display: inline-flex; align-items: center; gap: 8px;
  border: 1px solid var(--line-2); border-radius: 999px;
  padding: 8px 15px; cursor: pointer;
  font-size: .9rem; color: var(--ink-2); line-height: 1.3;
}
.pillopt:hover { border-color: var(--accent); color: var(--ink); }
.pillopt:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.pillopt:has(input:checked) {
  background: var(--accent-2); border-color: var(--accent); color: var(--accent); font-weight: 600;
}
.pillopt--skip { border-style: dashed; color: var(--muted); }
.pillopt input[type="radio"] { margin: 0; }

/* Why a question is being asked. Quiet, and to the side of the answer rather
   than above it, because it is read once and never again. */
.why {
  font-size: 0.875rem; color: var(--muted); margin-top: 9px;
  padding-left: 11px; border-left: 2px solid var(--line-2);
}

/* One block of a draft. Deliberately plainer than a card: there are twenty of
   them on the page and twenty cards is a page of boxes. */
.blockrow {
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  padding: 14px 16px; margin-bottom: 10px; background: var(--card);
}
.blockrow__hd {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 9px;
}
.blockrow__kind {
  font-size: 0.875rem; letter-spacing: .12em; text-transform: uppercase;
  color: var(--muted); font-weight: 600;
}
.blockrow__acts { margin-left: auto; display: flex; gap: 6px; align-items: center; }
.blockrow textarea { min-height: 76px; }
.blockrow .field { margin-bottom: 8px; }
.blockrow .field:last-child { margin-bottom: 0; }

/* **The one save, and it follows you down the page.**

   There used to be a button under every block. One button at the end of a
   twenty-block document is the right number, but it is also a long way from
   the block you have just finished typing in — so it sits at the bottom of the
   window until the end of the list reaches it, at which point it stops and
   stays where it was put. `position: sticky` with `bottom` does exactly that
   and needs nothing else.

   The tinted bar behind it matters: without one the button floats over the
   text it is covering and reads as part of whichever block is behind it. */
.blocksave {
  position: sticky; bottom: 0; z-index: 3;
  margin: 14px 0 0; padding: 12px 0 14px;
  background: linear-gradient(to top, var(--bg) 62%, transparent);
  display: flex; justify-content: flex-end;
}
@media (max-width: 640px) {
  .blocksave .btn { width: 100%; }
}

/* Above the phone's tab bar, which is fixed to the floor and would otherwise
   sit on top of the one button this page has. The same offset `.savebar` uses,
   and released again once the bar itself is gone. */
body:has(.tabbar) .blocksave { bottom: calc(var(--tabh) + env(safe-area-inset-bottom, 0px)); }
@media (min-width: 760px) {
  body:has(.tabbar) .blocksave { bottom: 0; }
}

@media print { .blocksave { display: none; } }

/* The tier table on the option editor: narrow numeric columns that stay
   readable when the fee is typed over. */
/* px, not rem. The 18px root in the back office is only safe because nothing
   but type is measured in rem; a column width in rem is a column that grows
   12.5% behind the counter and stays put on the customer side. */
.tiergrid { display: grid; grid-template-columns: 112px 144px 1fr; gap: 8px 12px; align-items: end; }
@media (max-width: 560px) { .tiergrid { grid-template-columns: 1fr; } }

/* ---------------------------------------------------------------------------
   Choosing a photograph

   It was a dropdown reading 照片 1 … 照片 13. Which is which is exactly the one
   thing a person choosing a photograph needs to know, and a numbered list is
   the only presentation that cannot tell them — Gan opened it and asked 「where
   to see the photos?」, which is the question the control was creating.

   Radios behind pictures. The input is made invisible and left in the layout
   rather than hidden with `display:none`, because a hidden input cannot be
   reached by a keyboard and this is the control the block turns on — the same
   reasoning as `.sessioncard`, and the label carries the focus ring on its
   behalf.
   ------------------------------------------------------------------------- */
.picks {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
  gap: 8px; margin-bottom: 10px;
}

.pick {
  position: relative; display: block; cursor: pointer;
  /* The photograph chooser on a proposal. Its unselected border is the only
     thing saying where one choice ends and the next begins. */
  min-height: 44px;
  aspect-ratio: 3 / 2; border-radius: var(--radius-sm);
  border: 1px solid var(--line-2); overflow: hidden; background: var(--soft);
}
.pick img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pick:hover { border-color: var(--accent); }

.pick > input[type="radio"] {
  position: absolute; width: 1px; height: 1px; opacity: 0; margin: 0;
  pointer-events: none;
}
.pick:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.pick:has(input:checked) {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 2px var(--accent);
}

/* The tick, so the choice survives a screenshot, a print and a colour-blind
   reader — a border alone says 「this one」 only if you can see the hue. */
.pick__on {
  position: absolute; inset-block-start: 6px; inset-inline-end: 6px;
  width: 20px; height: 20px; border-radius: 999px;
  background: var(--accent); color: var(--on-accent);
  display: none; place-items: center; font-size: 0.875rem; font-weight: 700;
}
.pick:has(input:checked) .pick__on { display: grid; }

/* 「no photograph」 is a choice too, so it is a tile like the others. */
.pick--none {
  display: grid; place-items: center; aspect-ratio: 3 / 2;
  color: var(--muted); font-size: 0.875rem; text-align: center; padding: 6px;
  border-style: dashed;
}

/* ── 礼券卡 ────────────────────────────────────────────────────────────────
   The page a voucher card points at. Deliberately narrow and quiet: somebody
   has just been given a present and the only two things they need are to see it
   and to be able to read the number off it.

   The card keeps its 3:2 aspect through `aspect-ratio` so the page does not
   reflow when the image arrives — on a phone, on a chat's in-app browser, that
   jump is the difference between reading the note and losing your place. */
.vcard { max-width: 760px; margin: 0 auto; padding: 8px 0 40px; }
.vcard__art {
  display: block; width: 100%; height: auto; aspect-ratio: 3 / 2;
  border-radius: var(--radius, 8px);
  box-shadow: 0 1px 2px rgba(40,30,20,.08), 0 24px 48px -32px rgba(40,30,20,.5);
}
.vcard__code { margin: 26px 0 0; text-align: center; }
.vcard__code-lab {
  font-size: .875rem; letter-spacing: .14em; text-transform: uppercase;
  color: var(--muted, #7A6E64);
}
/* Big, monospaced-ish and selectable. This is the string somebody reads down a
   phone to the shop, or pastes into the basket — the picture above cannot be
   copied and a screen reader cannot see it at all. */
.vcard__code-val {
  font-size: 1.9rem; font-weight: 600; letter-spacing: .08em;
  margin: 6px 0 4px; user-select: all; font-variant-numeric: tabular-nums;
}
.vcard__code-meta { font-size: .92rem; color: var(--muted, #7A6E64); }
.vcard__how { text-align: center; color: var(--muted, #7A6E64); margin: 14px 0 0; }
.vcard__note {
  margin: 22px 0 0; padding: 14px 16px; border-radius: var(--radius, 8px);
  background: var(--mist, #E8ECE4); text-align: center;
}
.vcard__note--warn { background: #F6EEDE; }
.vcard__acts { display: flex; gap: 10px; justify-content: center; margin-top: 26px; flex-wrap: wrap; }

/* ---------------------------------------------------------------------------
   The handbook — /admin/help
   ---------------------------------------------------------------------------

   **Why this page uses a different typeface from the rest of the back office.**

   The interface font ships as one small always-loaded chunk plus seven range
   files, and `tests/fonts.php` keeps every interface word inside the small
   chunk so no page ever pulls a range file to draw a label. The handbook does
   not fit in that chunk and never could: 679 distinct characters, 161 of them
   outside it. Letting it fall through to the range files was measured — all
   seven get pulled, **1.2 MB**, onto a phone, to read one page, on the shop's
   mobile data.

   So the prose is set in the reader's own device font: zero bytes, instant,
   and it has every character. Two rules opt back out of it. `.man-defs dt`
   quotes the interface back at the reader, so a label in the book looks like
   the label on the button; every one of those quotations is checked against
   the small chunk by tests/manual.php, which is what keeps the exception free.
   `.man-box__head` is the other, and it is free for a different reason: 注意
   and 记住 come out of `lang/`, which tests/fonts.php already holds to the
   chunk. Everything else here — headings, steps, the chapter badges — inherits
   the device stack.

   The type is a step larger than the rest of the admin (`html.admin` is
   already 112.5%): this is the one screen that is read rather than operated,
   by the person who is least sure of themselves, often on a phone held at
   arm's length.
   --------------------------------------------------------------------------- */

.manual {
  /* Not a token: nowhere else in the app wants a face chosen for having every
     glyph rather than for looking like the brand. */
  font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
               "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC",
               system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  max-width: 46em;          /* ~65 characters of Latin, ~34 of Chinese */
  line-height: 1.95;
  font-size: 1.0625rem;
}

/* Headings inherit the reader's face too. Without this they fall to the global
   `h1, h2, h3 { font-family: var(--sans) }` rule, whose stack names LL Round —
   and chapter titles are prose, not labels, so three range files came down
   after all. This one line was worth 525 KB on a phone. */
.manual h1, .manual h2, .manual h3 { font-family: inherit; }

.man-blurb { margin: 0 0 18px; color: var(--ink-2); font-size: 1rem; }

.manual h1 { font-size: 1.75rem; margin: 0 0 6px; }
.manual .man-sub { margin: 0 0 22px; font-size: .9375rem; }
.man-intro { margin: 0 0 14px; }

.man-how { margin: 26px 0; }
.man-how h2 { font-size: 1.1875rem; margin: 0 0 12px; }

.man-print { margin: 22px 0 0; }

/* Contents ---------------------------------------------------------------- */
.man-toc { margin: 26px 0 34px; }
.man-toc h2 { font-size: 1.1875rem; margin: 0 0 14px; }
.man-toc__part {
  margin: 18px 0 6px; font-size: .9375rem; font-weight: 600;
  letter-spacing: .06em; color: var(--muted);
}
.man-toc__part:first-of-type { margin-top: 0; }
.man-toc__list { margin: 0; padding-left: 1.6em; }
/* 44px of thumb per line. A contents list on a phone is nothing but touch
   targets, and 28 of them stacked at text height is a page you cannot use. */
.man-toc__list li { margin: 0; }
.man-toc__list a {
  display: block; min-height: 44px; padding: 9px 0;
  line-height: 1.45; text-decoration: none;
}
.man-toc__list a:hover, .man-toc__list a:focus-visible { text-decoration: underline; }

/* Parts and chapters ------------------------------------------------------ */
.man-part {
  margin: 46px 0 0; padding-top: 22px; font-size: 1.4375rem;
  border-top: 2px solid var(--line-2);
}
.man-ch { margin: 30px 0 0; scroll-margin-top: 84px; }
.man-ch h3 {
  font-size: 1.25rem; margin: 0 0 8px; line-height: 1.45;
  display: flex; align-items: baseline; gap: 12px;
}
/* The chapter number, so "see chapter 12" is a thing somebody can say out loud
   across a counter. Numbered here rather than in the source, because the
   source is ordered by job and renumbering it by hand is how a contents list
   starts lying. */
.man-ch__n {
  flex: none; min-width: 1.6em; font-size: .9375rem; font-weight: 600;
  color: var(--on-accent); background: var(--accent);
  border-radius: var(--radius-sm); padding: 2px 8px; text-align: center;
  font-variant-numeric: tabular-nums;
}
.man-when { margin: 0 0 16px; font-size: .9375rem; color: var(--muted); }
.man-when__k {
  font-weight: 600; letter-spacing: .04em;
}
.man-when__k::after { content: "："; }
:root[lang="en-MY"] .man-when__k::after,
.manual:lang(en) .man-when__k::after { content: ": "; }

.manual p { margin: 0 0 14px; }
.man-back { margin: 18px 0 0; font-size: .9375rem; }
.man-back a { display: inline-block; min-height: 44px; padding: 11px 0; }

/* The chapters three releases are about to invalidate ---------------------- */
.man-vol {
  margin: 0 0 16px; padding: 10px 14px; font-size: .9375rem;
  border-left: 4px solid var(--amber); background: var(--amber-2);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0; color: var(--ink);
}
.man-vol strong { display: block; }

/* Steps ------------------------------------------------------------------- */
.man-steps { margin: 0 0 18px; padding-left: 1.7em; }
.man-steps li { margin: 0 0 12px; }
/* What you press, then what should happen. Two lines rather than one sentence
   because they answer two different questions, and the second one is the one
   that tells a nervous reader they have not broken anything. */
.man-do { display: block; }
.man-see {
  display: block; font-size: .9375rem; color: var(--muted); margin-top: 2px;
}
.man-see::before { content: "→ "; }

.man-list { margin: 0 0 18px; padding-left: 1.5em; }
.man-list li { margin: 0 0 8px; }

/* Screen labels and what they mean ---------------------------------------- */
.man-defs { margin: 0 0 18px; }
.man-defs dt {
  /* The interface face, deliberately: this is the word as it appears on the
     button. Every one of these was checked against the always-loaded chunk. */
  font-family: var(--sans);
  font-weight: 600; margin: 14px 0 2px;
}
.man-defs dt:first-child { margin-top: 0; }
.man-defs dd { margin: 0 0 0 1.2em; color: var(--ink-2); }

/* Boxes ------------------------------------------------------------------- */
.man-box {
  margin: 0 0 18px; padding: 14px 16px; border-radius: var(--radius-sm);
  border: 1px solid var(--line-2);
}
.man-box p:last-child { margin-bottom: 0; }
.man-box__head {
  font-family: var(--sans);
  font-size: .875rem; font-weight: 600; letter-spacing: .1em;
  text-transform: uppercase; margin: 0 0 6px;
}
.man-box__title { font-weight: 600; margin: 0 0 6px; }
/* Two kinds, and they are not decoration. "Worth knowing" saves a detour;
   "Careful" is a thing the software will really do to you, most of them with
   no undo. Different colour, different border weight, so the second one is
   findable by flipping through rather than by reading. */
.man-box--note { background: var(--soft); }
.man-box--note .man-box__head { color: var(--muted); }
.man-box--warn {
  background: var(--red-2); border: 2px solid var(--red); color: var(--ink);
}
.man-box--warn .man-box__head { color: var(--red); }

/* Screenshots ------------------------------------------------------------- */
.man-shot { margin: 0 0 20px; }
.man-shot img {
  display: block; max-width: 100%; height: auto;
  border: 1px solid var(--line-2); border-radius: var(--radius-sm);
}
.man-shot figcaption {
  margin-top: 8px; font-size: .9375rem; color: var(--muted);
}

@media (max-width: 560px) {
  .manual { font-size: 1rem; line-height: 1.85; }
  .man-ch h3 { font-size: 1.1875rem; }
  .man-part { font-size: 1.3125rem; }
}

/* On paper the handbook is a handbook: no back-to-contents links to follow,
   and nothing small torn in half by a page turn.
   `.man-ch` is deliberately allowed to break — a chapter runs to two or three
   pages and `avoid` on it would push most of the book down the page and leave
   the gaps. The things that must survive a page turn whole are the small ones:
   a warning box, one numbered step, a screenshot, and a heading with nothing
   under it. */
@media print {
  .man-back, .man-toc__list a { text-decoration: none; }
  .man-back, .man-print { display: none; }
  .man-ch h3, .man-part { break-after: avoid; }
  .man-box, .man-steps li, .man-shot { break-inside: avoid; }
  .man-defs dt { break-after: avoid; }
}

/* ---------------------------------------------------------------------------
   对话 — the WhatsApp conversation.

   A chat is the one screen in this back office that is genuinely a *stream*
   rather than a list, so it gets its own small block rather than being forced
   into `.cardtable`. Everything else here is the house kit: the same tokens,
   the same 14px floor, the same 44px targets.

   The bubbles are asymmetric on purpose — theirs on the left in the card
   colour, ours on the right in the accent wash. That is the arrangement every
   messaging app has taught everybody to read, and inventing a different one
   here would cost a moment's thought on every screen for no gain.
   --------------------------------------------------------------------------- */
.chat { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 18px; }

.chat__row { display: flex; }
.chat__row--out { justify-content: flex-end; }

.chat__bubble {
  max-width: min(78%, 46ch);
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: 14px 14px 14px 4px;
  padding: 10px 12px;
}
.chat__bubble--out {
  background: var(--accent-2);
  border-color: var(--accent);
  border-radius: 14px 14px 4px 14px;
}

/* `anywhere`, not `break-word`: a guest can paste a 60-character URL with no
   spaces in it, and without this the bubble grows past the screen and takes
   the page's horizontal scroll with it. */
.chat__body { font-size: .9375rem; line-height: 1.6; overflow-wrap: anywhere; }

.chat__meta {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  margin-top: 6px; font-size: .875rem; color: var(--muted);
}
.chat__tick   { letter-spacing: -1px; }
.chat__failed { color: var(--red); font-weight: 600; }

/* A photograph a guest sent. Capped rather than full-width: the point of it in
   the stream is to be recognisable enough to decide whether to open it. */
.chat__img {
  display: block; max-width: 100%; max-height: 320px;
  border-radius: 10px; margin-bottom: 6px;
}
.chat__file {
  display: inline-block; min-height: 44px; line-height: 44px;
  padding: 0 12px; border-radius: 10px;
  background: var(--soft); text-decoration: none;
}

@media (max-width: 560px) {
  /* More of the screen on a phone, where 78% leaves a stripe of nothing down
     one side and the bubbles get narrow enough to break sentences oddly. */
  .chat__bubble { max-width: 88%; }
}
