/* ---------------------------------------------------------------------------
   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;
  /* ---------------------------------------------------------------------
     **The rail** (086). A back office is not a shop window: the navigation
     is the one thing on the screen that is never the work, so it is dark in
     both themes and the work is the lit part. Its own tokens because the
     card palette cannot carry light-on-dark, and it is not a boundary token,
     so this is the one place it is set.
     --------------------------------------------------------------------- */
  --rail:     #10231A;
  --rail-2:   #1A3325;
  --rail-ink: #DCE8DA;
  --rail-dim: #92AA99;
  --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);

  /* **The Chinese is set in the system's own interface face — 098.**

     Gan, 18 September: 「you can change the font, make it more professional.
     now everything is like a bit too big.... make it elegance and neat」.

     The face was 阿里妈妈方圆体 — a **圆体**, a rounded face. Rounded is warm and
     rounded is informal, and no amount of resizing argues with the shape of
     the letterforms. Asked which way to go, he took the system faces.

     So the order changed and nothing was deleted. `PingFang SC` now comes
     first among the Chinese faces, which is what a Mac and every iPhone draw
     their own interfaces in; `Microsoft YaHei` on Windows, `Noto Sans SC`
     everywhere else. Upright, even, and already on the machine.

     `LL Round` moved to the **end**, after every system face. A stack picks
     the first family that has the character, so on any device with a Chinese
     font it is never reached and never downloaded — but it is still there for
     a machine with no CJK face at all, which is the case it was always
     genuinely needed for. Two consequences worth writing down:

       * The 223 characters missing from the subset stop being a shipping
         problem. They were only ever a problem because a missing character
         dropped to a different face *mid-word*; now one face draws all of
         them. See the note in tests/fonts.php.
       * The preload of `ll-round-ui.woff2` came out of the layouts with this
         change. Preloading a file that will not be used is the most expensive
         kind of dead weight — it is fetched eagerly, ahead of things that are.

     The Latin face is unchanged and still first, because it carries the
     English and the numerals and one typeface trying to do both scripts does
     one of them badly.

     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. That is why every Chinese
     family here is an SC one, and why none of them may be swapped for a TC
     name without reading this paragraph first. */
  --sans: "Google Sans Flex", "PingFang SC", "Microsoft YaHei", "Noto Sans SC",
          "Noto Sans CJK SC", "Source Han Sans SC", -apple-system,
          BlinkMacSystemFont, "Segoe UI", Roboto, "LL Round", 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. */
  /* Follows `--sans` exactly, as it has since that change — including the move
     to the system faces in 098. Two tokens, one stack: if they ever differ,
     the interface has two voices again without anybody deciding it should. */
  --serif: "Google Sans Flex", "PingFang SC", "Microsoft YaHei", "Noto Sans SC",
           "Noto Sans CJK SC", "Source Han Sans SC", -apple-system,
           BlinkMacSystemFont, "Segoe UI", Roboto, "LL Round", 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;

  /* **Loosening, halved in 098.**

     Latin and Chinese want different amounts of air. CJK glyphs fill their box
     edge to edge and need a little; Latin loosened by the same amount starts
     to fall apart.

     These were .04em on CJK and .05em on headings, and they were set for the
     rounded face — a 圆体 has soft, open counters and carries extra tracking
     well. PingFang does not: it is already evenly spaced and drawing it loose
     reads as a heading that has been letter-spaced on purpose, on every line
     of body copy in the application. Gan's 「everything is like a bit too big」
     was three things multiplied together, and this was one of them.

     Headings keep slightly more than body, which is the one place tracking
     still earns its keep. */
  --track:      .01em;
  --track-cjk:  .02em;
  --track-head: .015em;

  color-scheme: light;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ground:   #0C110E;
    --app:      #141A17;
    --rail:     #0A110D;
    --rail-2:   #16241A;
    --rail-ink: #D3E2D6;
    --rail-dim: #869C8D;
    --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;
  --rail:     #0A110D;
  --rail-2:   #16241A;
  --rail-ink: #D3E2D6;
  --rail-dim: #869C8D;
  --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%; }

/* **The eighth came back off in 098.**
   ---------------------------------------------------------------------------
   Everything above is the argument for 112.5%, and it was a good one: the back
   office is read forty times a day by the same two people, and one of them had
   asked for larger type. It held for forty releases.

   What it did not survive is the rest of the interface growing up around it.
   At 18px with a 1.8 line-height and .04em on every character, a card of six
   rows filled a screen, and Gan — the reader it was raised for — asked for the
   opposite: 「now everything is like a bit too big.... make it elegance and
   neat」. Offered 15px, 16px and 17px, he took 16.

   **Still a percentage, and still `100%` rather than `16px`.** That is the
   whole of the paragraph above and none of it is undone: the number is
   whatever the reader has set in their browser, so somebody who has raised
   their default still gets it raised. What has gone is this application
   deciding, on top of that, that it knows better by an eighth.

   And because every spacing value in this stylesheet is in `px` — which
   tests/legibility.php enforces — this line moves the words and leaves every
   button, row and tap target exactly where it was. */
html.admin { font-size: 100%; }

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;
  /* 1.8 was a reading measure — right for an article, and this is not one.
     Five rows of a booking list at 1.8 take the vertical space of eight at
     1.6, and the words are no easier to read for it. 1.6 is still generous
     for Chinese, which needs more than Latin does. */
  line-height: 1.6;
  letter-spacing: var(--track-cjk);
  /* Word-spacing came out entirely in 098. It was .04em on top of the letter
     tracking, and the two compounded: Latin sentences were being opened up
     twice over for a reason that only ever applied to the Chinese. */
  -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.

   **The scale tightened in 098.** 1.45 / 1.15 / 1 was set against an 18px
   root, where an h1 came out at 26px — a magazine headline on a page whose
   job is a list of bookings. Against 16px these give 20 / 17 / 15, which is
   the shape of a heading in an application rather than on a poster, and the
   step between the three is still clearly a step. */
h1, h2, h3 { font-family: var(--sans); font-weight: 600; line-height: 1.35; margin: 0 0 .6em;
             letter-spacing: var(--track-head); }
h1 { font-size: 1.25rem; }
h2 { font-size: 1.0625rem; }
h3 { font-size: 0.9375rem; }
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;
}

/* --- The sidebar (078) -------------------------------------------------------
   Every section down the left, all visible at once; the one you are in opens
   beneath its name. `.adminnav` is still the admin's navigation and `.subnav`
   is still the list of a section's pages — the same two names as the old top
   rows, so the suite's reasoning about them (they trade places with the phone
   bar at 760px; neither may vanish unconditionally) carries over unchanged.
   ------------------------------------------------------------------------- */
/* **Two variables, and they must stay two — 096.**
   ---------------------------------------------------------------------------
   `--header-h` is what the bar is told to be **at least**. `--bar-h` is how
   tall it turned out, measured by app.js and read by the things that have to
   sit below it — the rail, and the messenger.

   They were one variable for about an hour and it took the back office down.
   `.site-header__inner` sets `min-height: var(--header-h)`, so when the script
   measured the bar and wrote the answer back into that same name, the bar was
   made at least that tall, which measured taller, which was written back: the
   header grew 44px on every pass until it filled the screen and the work was
   somewhere below the fold. Reported by Gan within the hour, with a screenshot
   of a bar 600px tall.

   The rule that comes out of it: **a measurement may never be written to a
   name that anything upstream of the measurement can read.** Hence the second
   name. `tests/sidebar.php` holds the two apart.

   66px is what the bar was asked for before any of this and still is — this
   line is not where the rail's problem was. */
html.admin { --header-h: 66px; --bar-h: 72px; }
.shell { display: flex; align-items: flex-start; }
/* **The rail does not travel with the work — 095.**
   ---------------------------------------------------------------------------
   Gan, 18 September, with a long booking scrolled to its foot and the rail
   gone from the top of it: 「left panel shall be (not) scrollable.. not link to
   right side」.

   It was already `position: sticky`, and it was sticking to nothing, because
   of the one line that is now `align-self: flex-start`. It read `stretch`: the
   rail was drawn as tall as the flex line it sits in — as tall as the booking
   beside it — and a sticky box can only travel **inside its own containing
   block**. A box already the full height of that block has nowhere to travel
   to, so it rode the page down like any other element, and the taller the
   record on the right, the further 今日 and 预约 went past the top of the
   window. Hence 「not link to right side」: the two panes were sharing one
   height.

   So the rail is given a height of its own — the window, less the bar above it
   — and that is the whole fix. It fills the screen whatever is beside it, it
   stays put, and when the sections outgrow the window it scrolls **in its own
   box**, with `overscroll-behavior: contain` so that reaching the end of the
   list does not hand the wheel back to the page behind it.

   **And `fixed` rather than `sticky`.** Sticky was tried first and is still
   wrong, for a reason worth writing down: a sticky box may not leave its
   containing block, and `.shell` stops above the footer. Scrolled to the foot
   of a long booking — which is exactly the screen Gan sent — the rail was
   dragged up by the height of the footer and 今日 went behind the bar again.
   Fixed has no containing block to be pulled out of: `top` is the bar, bottom
   is the window, and nothing on the right can move it. The bar is itself
   sticky at the top of the window, so the two always meet.

   Out of flow means the rail no longer holds the column open, so `.shell` and
   the footer keep its width clear themselves, below. */
.sidebar {
  position: fixed; top: var(--bar-h, 72px); left: 0; bottom: 0;
  width: 236px; z-index: 10;
  overflow-y: auto; overscroll-behavior: contain;
  padding: 14px 10px 24px;
  background: var(--card); border-right: 1px solid var(--line);
}
/* The width the rail is standing in. On the footer too, or the shop's name
   would be printed underneath it. */
.shell, body.admin .site-footer { padding-left: 236px; }
.shell > main { flex: 1 1 auto; min-width: 0; }

.adminnav { display: flex; flex-direction: column; gap: 2px; }
.navgroup { position: relative; }
.navgroup__link {
  display: flex; align-items: center; gap: 11px;
  min-height: 44px; padding: 0 12px; padding-right: 48px;
  border-radius: var(--radius-sm);
  color: var(--ink-2); text-decoration: none; font-size: 1rem; font-weight: 500;
}
.navgroup__link:hover { background: var(--soft); color: var(--ink); }
.navgroup__link[aria-current="page"], .navgroup.is-open > .navgroup__link { background: var(--accent-2); color: var(--accent); font-weight: 600; }
.navgroup__icon { width: 22px; flex: 0 0 22px; display: inline-flex; justify-content: center; opacity: .85; }
.navgroup__icon svg { width: 20px; height: 20px; }
.navgroup__label { flex: 1; }
.navgroup__toggle {
  position: absolute; top: 0; right: 0;
  width: 44px; min-width: 44px; height: 44px; min-height: 44px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 0; background: none; color: var(--muted); cursor: pointer; border-radius: var(--radius-sm);
}
.navgroup__toggle:hover { color: var(--ink); background: var(--soft); }
.navgroup__toggle svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: transform .15s ease; }
.navgroup__toggle[aria-expanded="true"] svg { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) { .navgroup__toggle svg { transition: none; } }

.subnav { display: flex; flex-direction: column; gap: 1px; padding: 2px 0 8px 33px; }
.subnav a {
  display: flex; align-items: center; min-height: 44px; padding: 0 12px;
  border-radius: var(--radius-sm);
  color: var(--muted); text-decoration: none; font-size: .95rem;
}
.subnav a:hover { background: var(--soft); color: var(--ink); }
.subnav a[aria-current="page"] { color: var(--accent); font-weight: 600; }

/* One button each for language and theme (079). */
.iconbtn {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  min-width: 44px; min-height: 44px; padding: 0 10px;
  border: 1px solid var(--line-2); border-radius: 22px; background: var(--card);
  color: var(--ink); text-decoration: none; font: inherit; font-size: .93rem; font-weight: 500; cursor: pointer;
}
.iconbtn:hover { background: var(--soft); border-color: var(--accent); }
.iconbtn svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }

/* 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) {
  /* The phone has the bar at the bottom; the sidebar and the navigation
     inside it go away. The bar's height is reserved on the body itself
     (see body:has(.tabbar)). */
  .sidebar { display: none; }
  .adminnav { display: none; }
  /* ...and with it the room kept clear for it, or every phone page would be
     indented by a rail that is not there. */
  .shell, body.admin .site-footer { padding-left: 0; }
}

/* --- 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; }
/* A row's controls sit side by side, right-aligned — the 学员 roll has a
   view link next to its relink button and neither should wrap under the other. */
td.roll__tools { text-align: right; white-space: nowrap; }
td.roll__tools > * { display: inline-flex; vertical-align: middle; margin-left: 6px; }

/* --- 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, .sidebar { display: none !important; }
  /* A fixed rail would otherwise print on every sheet, and the indent it
     leaves would print on all of them too. */
  .shell, body.admin .site-footer { padding-left: 0 !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 --------------------------------------------------------- */
/* The segments, as a filter bar (091). They replaced `.seggrid`/`.segcard`,
   which drew nine boxes with a 1.6rem numeral in each — a scoreboard for
   something that is not a score but a way of cutting the list. A chip is the
   name with its count riding on it; an empty segment stays, quietened, so the
   bar does not change shape from one week to the next. */
.segbar { display: flex; flex-wrap: wrap; gap: 8px; }
.segchip {
  display: inline-flex; align-items: center; gap: 8px;
  min-height: 44px; padding: 0 12px; border-radius: 999px;
  border: 1px solid var(--line-2); background: var(--app);
  color: inherit; text-decoration: none; font-size: 0.9375rem;
}
.segchip:hover { border-color: var(--accent); color: inherit; }
.segchip__n {
  font-family: var(--mono); font-weight: 600; font-variant-numeric: tabular-nums;
  color: var(--accent);
}
.segchip--none { color: var(--muted); }
.segchip--none .segchip__n { color: var(--muted); font-weight: 500; }

/* --- 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;
}
/*
 * 137 · a touched pill is not a chosen pill.
 *
 * B-202610-001 was taken by e-wallet and recorded as cash. `:hover` used to
 * give an unselected pill the accent border and the accent text — everything
 * 「chosen」 looked like except a background two shades off the card, which in
 * the dark theme is `#1D2E27` against a near-black. A touch screen has no
 * pointer to leave, so the tapped pill kept that state and read as selected
 * while the radio behind it had not moved.
 *
 * Hover is now confined to devices that actually have a pointer, and the
 * chosen pill is marked with a ✓ and a heavier border rather than relying on
 * colour alone — which also means it survives a dim phone screen in a tea
 * house, and a reader who does not separate those two greens.
 */
@media (hover: hover) and (pointer: fine) {
  .pm:hover { border-color: var(--accent); color: var(--accent); }
}
.pm.sel {
  background: var(--accent-2); border-color: var(--accent); color: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent); font-weight: 600;
}
.pm.sel::before { content: '\2713\00a0'; }

.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; } }

/* 存成图片 — 095.
   ---------------------------------------------------------------------------
   Under the receipt, the width of it, and hidden until app.js has run: without
   the script there is nothing to draw the picture with, and a button that does
   nothing is worse than no button. The document is unaffected either way — it
   is the page, and it still prints.

   Never on paper: a printed receipt with 「存成图片」 under it is a receipt with
   a stray sentence on it. */
.shotbox { display: none; }
.js .shotbox {
  display: block;
  max-width: 420px; margin: 14px auto 0; text-align: center;
}
.shotbox .shot { width: 100%; justify-content: center; }
.shotbox__hint { margin: 8px 0 0; }
@media print { .shotbox, .js .shotbox { display: none !important; } }

/* ---------------------------------------------------------------------------
   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.

   [data-rowdialog] is the same affordance for the lists whose rows open a
   panel over the page rather than going anywhere — 茶单's teas. Same rules,
   because to the finger it is the same row.
   ------------------------------------------------------------------------- */
tr[data-rowlink],
tr[data-rowdialog] { cursor: pointer; }
tr[data-rowlink]:hover,
tr[data-rowdialog]: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,
tr[data-rowdialog]: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,
  tr[data-rowdialog]:hover { background: transparent; }
  tr[data-rowlink]:active,
  tr[data-rowdialog]: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;
}
/* A sentence riding on the heading rather than a paragraph under it. 091: the
   one under 要处理的 was a full line of its own above a row of tiles that then
   said the same thing in numbers; beside the heading it explains without
   taking a row. Not uppercase and not tracked out, because it is a sentence. */
.sectionhead__note {
  margin-left: 10px; font-weight: 400; font-size: 0.875rem;
  letter-spacing: 0; text-transform: none; color: var(--muted);
}

.lede { color: var(--muted); font-size: 1rem; margin: 6px 0 4px; max-width: 62ch; }

/* --- 要做的事, beside what the room is about to receive (093) ---------------
   The worklist came back when its rows started naming the thing rather than
   restating the tile above them. A row is a count in a soft square, the job,
   the item, and a chevron — the shape from the mockup, at the type sizes this
   application holds itself to rather than the mockup's 13px. */
.work { list-style: none; margin: 0; padding: 0; }
.work li { border-top: 1px solid var(--line); }
.work li:first-child { border-top: 0; }
.work__row {
  display: flex; align-items: center; gap: 12px; width: 100%;
  min-height: 44px; padding: 9px 14px;
  background: transparent; color: inherit; text-decoration: none;
}
a.work__row:hover { background: var(--soft); color: inherit; }
.work__n {
  width: 32px; height: 32px; flex: 0 0 32px; border-radius: var(--radius-sm);
  display: grid; place-items: center;
  font-family: var(--mono); font-weight: 600; font-size: 0.9375rem;
  font-variant-numeric: tabular-nums;
  background: var(--soft); color: var(--muted);
}
.work__row--do .work__n    { background: var(--accent-2); color: var(--accent); }
.work__row--money .work__n { background: var(--amber-2); color: var(--amber); }
.work__t { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.work__t b { font-weight: 600; font-size: 0.9375rem; }
.work__t span {
  font-size: 0.875rem; color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.work__row--quiet .work__t b { font-weight: 500; color: var(--muted); }
.work__icon {
  width: 18px; height: 18px; flex: 0 0 18px;
  fill: none; stroke: var(--muted); stroke-width: 2;
}

/* Two columns on a desk, one on a phone. The list is the wider of the two
   because its rows carry a sentence; the cards beside it answer one question
   each and want no more room than that. */
.pair { display: grid; gap: 12px; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); align-items: start; }
.pair__side { display: grid; gap: 12px; align-content: start; }
@media (max-width: 899px) { .pair { grid-template-columns: 1fr; } }

/* A card with a titled bar, a body and a row of controls at the foot — the
   mockup's panel. Opt-in rather than applied to every `.card`, because most
   cards in this application are a title and some content and want the single
   padding they already have. */
.card--panel { padding: 0; overflow: hidden; }
.card--panel .card__head {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--card);
}
.card--panel .card__head .pill { margin-left: auto; }
.card--panel .card__body { padding: 14px; }
.card--panel .card__foot {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  padding: 10px 14px; border-top: 1px solid var(--line); background: var(--soft);
}
.card--panel .card__foot form { margin: 0; }
.card--panel .empty { padding: 14px; }

/* --- 设置 › 通知 (094) -----------------------------------------------------
   One box that says where *this* handset stands, and a button. The state line
   is written by app.js from wording carried on the element — there is no
   inline script anywhere in this application and this was not going to be the
   first, and the sentence has to come from lang/ or it exists in one
   language. */
.pushbox {
  border: 1px solid var(--line-2); border-radius: var(--radius-sm);
  background: var(--soft); padding: 14px;
}
.pushbox__state { margin: 0 0 10px; font-size: 0.9375rem; }
.pushbox__state[data-state="on"]      { color: var(--accent); font-weight: 500; }
.pushbox__state[data-state="denied"],
.pushbox__state[data-state="failed"]  { color: var(--amber); }
.pushbox__do { display: flex; gap: 8px; flex-wrap: wrap; }

/* The two taps, numbered, because the whole of the setup is knowing which. */
.pushhow {
  margin: 14px 0 0; padding-left: 1.4em;
  font-size: 0.9375rem; color: var(--muted);
}
.pushhow li { margin-bottom: 4px; }

/* 下一席 */
.nx__who { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.nx__who > span:last-child { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.nx__name { font-weight: 600; font-size: 1rem; }

/* The date rail on 今日 (079). */
.daterail { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
.daterail__day { color: var(--muted); font-size: .95rem; padding: 0 4px; }

/* 下一席 — the sitting still ahead of the clock. */
.sitting--next { border-color: var(--accent); }
.pill--next { background: var(--accent-2); color: var(--accent); }

/* Tomorrow, for the kitchen. */
.prep {
  border: 1px solid var(--line-2); border-radius: var(--radius); background: var(--card);
  padding: 14px 16px;
}
.prep__top { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; margin-bottom: 10px; }
.prep__day { font-weight: 600; font-size: 1.05rem; }
.prep__list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.prep__list li { display: flex; gap: 12px; font-size: 0.95rem; padding-bottom: 6px; border-bottom: 1px dashed var(--line); }
.prep__list li:last-child { border-bottom: 0; padding-bottom: 0; }
.prep__t { color: var(--muted); min-width: 3.2em; }
.phone-note { display: none; text-align: center; margin: 22px 0 0; }
@media (max-width: 759px) { .phone-note { display: block; } }

/* Products photographed on the phone, waiting for a price (079). */
.card--attn { border-color: var(--amber); }
.drafts { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.drafts li { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-height: 44px; }
.drafts li a { font-weight: 600; }

/* --- 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; }
/* A sitting with nobody in it is its own header and nothing else (091). */
.sitting--bare { margin-bottom: 6px; }
.sitting--bare .sitting__head { border-bottom: 0; background: var(--card); }
.sitting__nobody { margin-left: auto; }
/* 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; }

/* The name, the 停用 pill and the 「· 6 项」 count are one heading, so pressing
   any of them opens the category — the same widening the tea rows got, on the
   block that is not a table row. The arrows sit outside it and keep their own
   press. */
.cathead__main[data-rowdialog] { cursor: pointer; }
.cathead__main[data-rowdialog]:focus-within .cathead__name { text-decoration: underline; }
.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; }

/* A link inside a bubble — 097.
   ---------------------------------------------------------------------------
   Underlined, because on a coloured bubble colour alone is not a signal: the
   sent side is already accent-on-accent, and a link picked out only by hue
   there is a link nobody sees. It inherits the bubble's ink for the same
   reason and keeps the accent on the received side, where there is a plain
   ground to stand on. */
.chat__body a { text-decoration: underline; text-underline-offset: 2px; color: var(--accent); }
.chat__bubble--out .chat__body a { color: inherit; }
.chat__body a:hover { text-decoration-thickness: 2px; }

.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;
}

/* --- a voice note --------------------------------------------------------
   The browser's own player, sized to the bubble. Nothing here restyles the
   controls: the shadow parts differ in every engine, a half-restyled player
   is worse than a plain one, and an operator already knows what this looks
   like on their own phone. */
.chat__audio { margin-bottom: 6px; }
.chat__voice {
  display: block; width: 100%; min-width: 220px; max-width: 320px;
  /* Chrome draws the bar at 54px and Safari at 31px, and the play button
     inside a 31px bar is under the thumb minimum. The floor is the same 44px
     every other control on this site keeps. */
  min-height: 44px;
}
.chat__voice-get {
  display: inline-block; min-height: 44px; line-height: 44px;
  font-size: .875rem; color: var(--muted); text-decoration: none;
}
.chat__voice-get:hover { color: var(--ink); text-decoration: underline; }

/* 录口信, beside the paperclip. Red while it is listening, because that is
   what every recorder anyone has ever used does, and because a microphone
   left open by accident is the one mistake here that matters. */
.mic { white-space: nowrap; }
.mic--on {
  background: var(--red); border-color: var(--red); color: #fff;
  animation: mic-pulse 1.4s ease-in-out infinite;
}
@keyframes mic-pulse { 50% { opacity: .68; } }
@media (prefers-reduced-motion: reduce) {
  .mic--on { animation: 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%; }
}

/* 预约有变 — the send-update card on a booking (085). */
.update__row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
.update__row > div:first-child { flex: 1 1 260px; }

/* =========================================================================
   对话 as a messenger (085).

   Three panes on a desk — the conversations, the open one, the guest's
   booking — and one at a time on a phone. The list pane and the chat scroll
   on their own so the composer stays put; the whole thing is sized to the
   window under the header, like every messenger, rather than to the page.
   ========================================================================= */

.msgr {
  display: grid;
  grid-template-columns: 300px minmax(0, 1fr);
  gap: 0;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--card);
  overflow: hidden;
  min-height: 420px;
  height: calc(100vh - var(--bar-h, 72px) - 40px);
  max-height: 900px;
}
.msgr--thread { grid-template-columns: 300px minmax(0, 1fr) 300px; }
.msgr__blank { display: flex; align-items: center; justify-content: center; padding: 24px; text-align: center; }

/* --- The conversations ---------------------------------------------------- */
.convos { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--line); background: var(--app); }
.convos__tabs { display: flex; gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--line); }
.convos__list { list-style: none; margin: 0; padding: 0; overflow-y: auto; min-height: 0; }
.convos .empty { margin: 16px; }

.convo {
  display: flex; align-items: center; gap: 12px;
  min-height: 64px; padding: 8px 12px;
  text-decoration: none; color: inherit;
  border-bottom: 1px solid var(--line-2);
}
.convo:hover { background: var(--soft); color: inherit; }
.convo--on { background: var(--accent-2); }
.convo__mark {
  flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--soft); color: var(--accent); font-weight: 700;
}
.convo--on .convo__mark { background: var(--card); }
.convo__main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.convo__top, .convo__bottom { display: flex; align-items: center; gap: 8px; }
.convo__name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.convo__when { flex: 0 0 auto; font-size: .875rem; color: var(--muted); }
.convo__preview { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .875rem; color: var(--muted); }
.convo--unread .convo__preview { color: var(--ink); }
.convo__count {
  flex: 0 0 auto; min-width: 22px; height: 22px; padding: 0 7px; border-radius: 11px;
  background: var(--accent); color: var(--on-accent); font-size: .875rem; font-weight: 700;
  display: inline-flex; align-items: center; justify-content: center;
}
.convo__dot { flex: 0 0 8px; width: 8px; height: 8px; border-radius: 50%; background: var(--line); }
.convo__dot--open { background: var(--accent); }

/* --- The open conversation ------------------------------------------------ */
.thread { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.thread__head {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 14px; border-bottom: 1px solid var(--line);
}
.thread__back { display: none; }
.thread__who { flex: 1 1 auto; min-width: 0; }
.thread__name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.thread__tools { margin: 0; }
.thread__scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 14px 16px; background: var(--app); }
.thread__scroll .chat { margin: 0; }
.thread__closed { padding: 12px 16px; border-top: 1px solid var(--line); }

.chat__day { display: flex; justify-content: center; margin: 6px 0; }
.chat__day span { font-size: .875rem; color: var(--muted); background: var(--soft); border-radius: 999px; padding: 2px 10px; }
.chat__tick--read { color: var(--accent); }

/* --- The composer --------------------------------------------------------- */
.composer { border-top: 1px solid var(--line); padding: 10px 14px 12px; display: flex; flex-direction: column; gap: 8px; }
.composer__chips { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: thin; }
.qchip {
  flex: 0 0 auto; min-height: 44px; padding: 0 12px; border-radius: 999px;
  border: 1px solid var(--line-2); background: var(--soft); color: var(--ink);
  font: inherit; font-size: .875rem; cursor: pointer; white-space: nowrap;
  max-width: 260px; overflow: hidden; text-overflow: ellipsis;
}
.qchip:hover { border-color: var(--accent); }
.composer__box textarea { width: 100%; resize: vertical; }
.composer__row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
.composer__emoji { display: flex; gap: 2px; flex-wrap: wrap; }

/* **The paperclip — 097.**
   ---------------------------------------------------------------------------
   Gan, 18 September: 「i need to have ability to attached attachment」.

   A real `<input type="file">` with a `<label for>` in front of it. The input
   is moved out of sight rather than hidden: `display: none` takes an element
   out of the accessibility tree and off the keyboard's path, and this one has
   to stay reachable — the label is the visible control, but the input is what
   the label activates and what a screen reader announces. So it is one pixel,
   clipped, in the corner of the row, still focusable.

   The label sits beside 🙂 and is the same 44px as it is: the two do the same
   kind of job — put something in the message that is not typing. */
.clip {
  width: 44px; height: 44px; min-width: 44px; min-height: 44px;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 10px; cursor: pointer; color: var(--muted);
}
.clip:hover { background: var(--soft); color: var(--ink); }
.clip__icon { display: inline-flex; }
.clip__icon svg { width: 20px; height: 20px; }

/* Focus has to be drawn here, on the label, because the ring belongs to the
   input, and the input is the pixel nobody can see. */
.clip__input:focus-visible + .clip,
.clip:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }

.clip__input {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* Which file is on, written by app.js. */
.clip__has {
  display: inline-flex; align-items: center; gap: 4px;
  max-width: 46%; font-size: 0.875rem; color: var(--ink-2);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.clip__has--bad { color: var(--danger, var(--amber)); }
.clip__drop {
  min-width: 44px; min-height: 44px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 0; background: none; color: var(--muted); cursor: pointer;
  font-size: 1.125rem; line-height: 1; border-radius: 10px;
}
.clip__drop:hover { background: var(--soft); color: var(--ink); }

/* For a label that is read but not seen. */
.vh {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* --- The emoji picker (090) ----------------------------------------------
   A <details>, so opening and closing it is the browser's work and the panel
   is there with this stylesheet's script absent. The panel is taken out of
   the flow and put *above* the row: one that pushed the composer down would
   move 发出 out from under the thumb reaching for it, every time somebody
   went looking for 🙏. */
details.emoji { position: relative; }
details.emoji > summary { list-style: none; }
details.emoji > summary::-webkit-details-marker { display: none; }
.emojiopen {
  width: 44px; height: 44px; border-radius: 10px; border: 0; background: transparent;
  font-size: 1.375rem; line-height: 44px; text-align: center; cursor: pointer;
  user-select: none;
}
.emojiopen:hover,
details.emoji[open] > .emojiopen { background: var(--soft); }
.emojipanel {
  position: absolute; bottom: calc(100% + 8px); left: 0; z-index: 40;
  width: min(376px, 86vw); max-height: 320px; overflow-y: auto;
  padding: 8px; border-radius: 14px;
  border: 1px solid var(--line-2); background: var(--card);
  box-shadow: 0 18px 40px rgba(0, 0, 0, .22);
}
.emojipanel__label {
  position: sticky; top: -8px; z-index: 1;
  padding: 6px 4px 4px; font-size: .875rem; color: var(--muted);
  background: var(--card);
}
.emojigrid { display: grid; grid-template-columns: repeat(8, 1fr); }
.emojibtn {
  width: 44px; height: 44px; border-radius: 10px; border: 0; background: transparent;
  font-size: 1.375rem; line-height: 1; cursor: pointer;
}
.emojibtn:hover { background: var(--soft); }
.composer__left { margin-left: auto; }

/* --- The right-hand pane -------------------------------------------------- */
.side { display: flex; flex-direction: column; gap: 0; min-height: 0; overflow-y: auto; border-left: 1px solid var(--line); background: var(--app); }
.side__card { padding: 14px; border-bottom: 1px solid var(--line); }
.side__edit { display: inline-flex; margin-top: 10px; }
.side__actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.reopen { display: flex; gap: 8px; flex-wrap: wrap; }
.reopen input { flex: 1 1 200px; }

@media (max-width: 1099px) {
  /* Three panes need a wide desk. On a tablet the booking pane goes under
     the chat rather than squeezing it. */
  .msgr--thread { grid-template-columns: 260px minmax(0, 1fr); }
  .side { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); flex-direction: row; flex-wrap: wrap; }
  .side__card { flex: 1 1 280px; border-bottom: 0; }
  .msgr--thread { height: auto; max-height: none; }
  .msgr--thread .thread__scroll { max-height: 60vh; }
}
@media (max-width: 759px) {
  /* One pane at a time: the list is the inbox page, the chat is its own. */
  .msgr { grid-template-columns: minmax(0, 1fr); height: auto; max-height: none; border: 0; border-radius: 0; background: transparent; }
  .msgr--list .msgr__blank { display: none; }
  .msgr--thread .convos { display: none; }
  .thread__back { display: inline-flex; }
  .thread { border: 1px solid var(--line); border-radius: 14px; background: var(--card); overflow: hidden; }
  .thread__scroll { max-height: 55vh; }
  .side { border: 0; margin-top: 12px; }
  .side__card { border: 1px solid var(--line); border-radius: 14px; background: var(--card); margin-bottom: 10px; }
  .convos { border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
}

/* ===========================================================================
   The back office as a CRM — release 086.

   Gan, 17 September: 「i want you to redesign the interface for admin.. make
   it like a true backend system.. easy to access and view」 · 「do this like a
   real CRM backend - design」.

   Four things were wrong, and all four were layout rather than colour:

   1. **The content was capped at 1080px and centred.** On the 1440px screen
      the shop is actually run from, a third of the window was empty while the
      booking table scrolled sideways inside its card.
   2. **Everything was a card with 18px of padding**, so eight bookings filled
      a screen and a week could not be compared at a glance.
   3. **There was no top bar** — nowhere to say where you are, nowhere to
      search, and the status pills wrapped onto three rows on a phone.
   4. **The navigation looked like the work.** A back office's rail is the one
      part of the screen that is never the job, so it is dark in both themes
      and the work is the lit part.

   The reading size (18px root) and every control floor are unchanged: this is
   about how much of the screen the work gets, not about making it smaller.
   =========================================================================== */

/* --- the page itself ------------------------------------------------------ */
.adminmain {
  flex: 1 1 auto; min-width: 0;
  width: 100%;
  padding: 22px 22px 48px;
}

/* --- the top bar ---------------------------------------------------------- */
.site-header__inner {
  flex-wrap: nowrap;
  gap: 14px;
  padding: 10px 16px;
  min-height: var(--header-h, 66px);
}
html.admin .brand__logo { width: 34px; height: 34px; flex: 0 0 34px; }
html.admin .brand__name { font-size: 1rem; }
html.admin .brand__sub  { font-size: 0.875rem; }

.topbar__where {
  display: flex; align-items: baseline; gap: 10px;
  min-width: 0; padding-left: 6px;
  border-left: 1px solid var(--line);
  margin-left: 4px; padding-top: 2px; padding-bottom: 2px;
}
.topbar__title {
  font-weight: 600; font-size: 1rem; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis; max-width: 22ch;
}
.topbar__crumb { color: var(--muted); font-size: 0.875rem; white-space: nowrap; }

.topsearch { position: relative; flex: 1 1 260px; max-width: 420px; margin: 0; }
.topsearch svg {
  position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
  width: 18px; height: 18px; color: var(--muted);
  fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round;
  pointer-events: none;
}
.topsearch input[type="search"] {
  width: 100%; min-height: 44px; padding: 0 12px 0 38px;
  border: 1px solid var(--line-2); border-radius: var(--radius-sm);
  background: var(--app); color: var(--ink);
}
.site-header__tools { flex: 0 0 auto; }

/* --- the rail ------------------------------------------------------------- */
.sidebar {
  background: var(--rail);
  border-right: 0;
  padding: 12px 8px 24px;
}
.sidebar .navgroup__link,
.sidebar .navgroup__toggle { color: var(--rail-ink); }
.sidebar .navgroup__link:hover,
.sidebar .navgroup__toggle:hover { background: var(--rail-2); color: var(--rail-ink); }
.sidebar .navgroup__link[aria-current="page"] {
  background: var(--accent); color: var(--on-accent);
}
.sidebar .navgroup__link[aria-current="page"] .navgroup__icon { color: inherit; }
.sidebar .navgroup__icon { color: var(--rail-dim); }
.sidebar .subnav { border-left-color: var(--rail-2); }
.sidebar .subnav a { color: var(--rail-dim); }
.sidebar .subnav a:hover { color: var(--rail-ink); background: var(--rail-2); }
.sidebar .subnav a[aria-current="page"] { color: var(--rail-ink); font-weight: 600; }
.sidebar .navgroup__n { background: var(--rail-2); color: var(--rail-ink); }
.sidebar .navgroup__link[aria-current="page"] .navgroup__n {
  background: rgba(0, 0, 0, .22); color: inherit;
}

/* --- lists that are meant to be compared ---------------------------------- */
.adminmain .table-scroll { border-radius: var(--radius-sm); }
.adminmain table { font-size: 0.9375rem; }

/* **Column names, and why they are not sticky.**

   The first cut made them `position: sticky; top: var(--header-h)`. A ledger
   sits inside `.table-scroll`, which is an overflow container for the sideways
   case — and sticky inside an overflow container is measured from *that box*,
   not the viewport, so the header row detached and landed on top of the first
   booking. A header that follows you down needs the table not to be in a
   scroll container at all; a header that simply names its columns is worth
   more than that rearrangement. */
.adminmain .cardtable thead th,
.adminmain table.rows thead th {
  background: var(--soft);
  font-size: 0.875rem; font-weight: 600;
  letter-spacing: .03em; color: var(--muted);
  white-space: nowrap;
}

/* Figures line up under each other or they cannot be compared, which is the
   only reason a table is a table. */
.adminmain td.num, .adminmain th.num, .adminmain .mono {
  font-variant-numeric: tabular-nums;
}

/* The row is the target. A hover that lights the whole row is what makes a
   dense table readable across, and it is also what says 「this opens」. */
.adminmain .cardtable tbody tr:hover td { background: var(--soft); }

/* --- the toolbar above a list --------------------------------------------- */
.listtools {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  margin-bottom: 12px;
}
.listtools .ranger { margin: 0; }
/* A filter beside the search keeps its own width. Without this a bare
   `<select>` in the toolbar stretched to the full row and pushed itself onto a
   second line under a search box that had not asked for the space (091). */
.listtools > select { flex: 0 0 auto; width: auto; min-width: 150px; max-width: 240px; }
.listtools__right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.listtools__n { color: var(--muted); font-size: 0.875rem; white-space: nowrap; }

/* A row is one record, not a paragraph. 10px of cell padding under a 44px
   link made a 90px row, so eight bookings were a screen; the link keeps its
   target and the row keeps a fixed, comparable height. */
.adminmain th, .adminmain td { padding: 4px 12px; }
.adminmain .cardtable tbody td, .adminmain table.rows tbody td { height: 54px; }

/* Cards hold a group, they are not the point of the screen. 20px of padding
   on every one of them is what put eight bookings on a 900px screen. */
.adminmain .card { padding: 14px 16px; margin-bottom: 12px; border-radius: var(--radius-sm); }
.adminmain .card__title { margin-bottom: 8px; }

/* --- One page header, on every screen (091) --------------------------------
   Forty views each wrote their own: an <h1> with a .7em bottom margin, a lede
   with a 1em one, and on some of them a flex wrapper written inline. The
   result was a title block between 70 and 110px tall depending on which page
   you were on, above content that was already dense — and the bar at the top
   was meanwhile saying the same page name again.

   So the title is smaller than a marketing heading, the sentence under it sits
   right under it, and the block ends. Said here rather than in forty files,
   because that is what made them drift in the first place. */
/* The same size as every other h1 since 098. It was 1.375rem against an 18px
   root — 24.75px, a page title competing with the work underneath it — and the
   base scale has since come down to meet it. One answer rather than two. */
.adminmain h1 { font-size: 1.25rem; margin: 0 0 2px; line-height: 1.35; }
.adminmain .lede { font-size: 0.9375rem; margin: 0 0 16px; }
.adminmain h1 + .lede { margin-top: 0; }
.adminmain .todayhead, .adminmain .pagehead { margin-bottom: 4px; }
/* A page's own actions, beside its title. Written inline in five views, each
   with its own gap and alignment; one class now, so the next screen inherits
   the decision rather than copying whichever line was nearest. */
.pagehead {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 16px; flex-wrap: wrap;
}
.pagehead__acts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }

/* An empty list is a sentence, not a room. Behind the counter a screen often
   has two or three of these at once — the day, the ledger, the pager — and at
   22px of padding each they were 270px of dashed boxes saying nothing three
   times. The customer site keeps the roomier one; it shows at most one. */
.adminmain .empty { padding: 12px 14px; text-align: left; }

/* Search and range on one row: they are one question — 「which of these do I
   want to see」 — and they used to be two rows and 120px apart. */
.listtools .listsearch { margin: 0; flex: 1 1 260px; max-width: 420px; }
.listtools .ranger__pills { margin: 0; }

@media (max-width: 1099px) {
  .topbar__where { display: none; }
}
@media (max-width: 759px) {
  /* The bar carries four things on a phone, not seven: the way home, one box
     to search, and whatever is broken. */
  .site-header__inner { flex-wrap: wrap; padding: 8px 14px; }
  .topsearch { order: 3; flex: 1 1 100%; max-width: none; }
  .adminmain { padding: 16px 16px 96px; }
  html.admin .brand__sub { display: none; }
}

/* ===========================================================================
   The drawer (089) — one record, without leaving the list.

   The single biggest thing a back office does that this did not: opening a
   booking threw away the filter, the scroll and the row it was being compared
   against. It slides in over the list; the list is exactly where it was.

   `hidden` is what closes it, so with no JavaScript it is not on the page at
   all and every row goes on opening the booking's own page.
   =========================================================================== */
.scrim {
  position: fixed; inset: 0; z-index: 40;
  background: rgba(12, 20, 15, .38);
}
.drawer {
  position: fixed; z-index: 50;
  top: 0; right: 0; bottom: 0;
  width: min(460px, 100%);
  display: flex; flex-direction: column;
  background: var(--card);
  border-left: 1px solid var(--line);
  box-shadow: -18px 0 48px rgba(12, 20, 15, .18);
  overflow: hidden;
}
.drawer[hidden], .scrim[hidden] { display: none; }
.drawer__inner { display: flex; flex-direction: column; min-height: 0; height: 100%; }

.drawer__head {
  display: flex; align-items: center; gap: 10px;
  padding: 12px 14px; border-bottom: 1px solid var(--line);
}
.drawer__who { flex: 1 1 auto; min-width: 0; }
.drawer__name {
  font-weight: 600; font-size: 1rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.drawer__body { flex: 1 1 auto; overflow-y: auto; min-height: 0; padding: 14px; }
.drawer__foot {
  flex: 0 0 auto; display: flex; gap: 8px; flex-wrap: wrap;
  padding: 12px 14px; border-top: 1px solid var(--line); background: var(--soft);
}
.drawer__sec {
  font-size: 0.875rem; font-weight: 600; letter-spacing: .04em;
  color: var(--muted); margin: 18px 0 8px;
}
.drawer__edit { margin-top: 14px; }
.drawer__row { margin-top: 10px; }
.drawer__wait { color: var(--muted); padding: 22px 14px; }

/* The last few things that happened to this record. Newest first, because the
   question a drawer answers is 「what happened to this one」 and the answer is
   almost always the most recent line. */
.dwlog { list-style: none; margin: 0; padding: 0; }
.dwlog li {
  padding: 8px 0; border-top: 1px solid var(--line);
  display: flex; flex-direction: column; gap: 2px; font-size: 0.9375rem;
}
.dwlog li:first-child { border-top: 0; }

/* What is in the parcel (091). A line item is a row, not a stack: the name
   runs, the quantity and the money hold their own columns, and the figures
   line up down the drawer so two of them can be compared without reading. */
.dwitems { list-style: none; margin: 0; padding: 0; }
.dwitems li {
  display: flex; align-items: baseline; gap: 10px;
  padding: 8px 0; border-top: 1px solid var(--line); font-size: 0.9375rem;
}
.dwitems li:first-child { border-top: 0; }
.dwitems__name { flex: 1 1 auto; min-width: 0; }
.dwitems__q { flex: 0 0 auto; color: var(--muted); }
.dwitems__v { flex: 0 0 auto; font-variant-numeric: tabular-nums; }

/* The list underneath must not scroll while the drawer is open: a phone that
   scrolls the page behind a sheet loses the place the sheet was opened from. */
body.has-drawer { overflow: hidden; }

@media (max-width: 559px) {
  /* A 460px panel on a 390px screen is the page. Then it should look like one. */
  .drawer { width: 100%; border-left: 0; }
}

/* ===========================================================================
   The pieces the mockup had and the build did not — release 090.

   Gan, holding the two side by side: 「your output and your design artifect
   not same... i like the artefect design better」. Three things:
   the five numbers across 今日, the rail split into 日常 and 经营 with what is
   waiting beside each name, and 新建 in the bar.
   =========================================================================== */

/* --- the job board -------------------------------------------------------
   Seven tiles since 091, when the worklist folded into this strip: five jobs
   and the two facts that bracket them. 200px rather than 178 because a tile
   now carries the worklist's sentence — 「3 席已到还没开账单 · 本周收款 RM 0」
   — and a column too narrow for that turns every tile into four lines. */
.stats {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 12px; margin: 0 0 22px;
}
.stat {
  display: flex; flex-direction: column; gap: 2px;
  padding: 12px 14px; min-height: 44px;
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  text-decoration: none; color: inherit;
}
.stat:hover { border-color: var(--line-2); color: inherit; }
.stat__k { font-size: 0.875rem; color: var(--muted); font-weight: 500; }
.stat__v { font-family: var(--mono); font-size: 1.5rem; font-weight: 600; letter-spacing: -.01em; font-variant-numeric: tabular-nums; }
/* One line. The tile names the thing; the whole sentence is on the row
   underneath it, and a tile that wraps to three lines drags the row of them
   out of square for the sake of words that are repeated ten pixels below. */
.stat__d {
  font-size: 0.875rem; color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* A tile with work in it is amber, the same amber the worklist uses for the
   same meaning. A fact is quiet. One colour, one meaning — the August audit. */
.stat--do { background: var(--amber-2); border-color: var(--amber); }
.stat--do .stat__k, .stat--do .stat__v, .stat--do .stat__d { color: var(--amber); }

/* Tonight's takings had its own tone in the worklist and keeps it here, or
   the money tile would go quiet on the one evening it matters. */
.stat--money { background: var(--amber-2); border-color: var(--amber); }
.stat--money .stat__k, .stat--money .stat__v, .stat--money .stat__d { color: var(--amber); }

/* --- the rail's two halves ------------------------------------------------ */
.navlabel {
  font-size: 0.875rem; font-weight: 600; letter-spacing: .1em;
  text-transform: uppercase; color: var(--rail-dim);
  padding: 16px 12px 6px;
}
.navlabel:first-child { padding-top: 4px; }
.navgroup__n {
  margin-left: auto; margin-right: 4px;
  font-family: var(--mono); font-size: 0.875rem; font-weight: 600;
  min-width: 24px; text-align: center;
  padding: 1px 7px; border-radius: 999px;
  background: var(--rail-2); color: var(--rail-ink);
}
/* The chevron sits at the right of the row, so the count stops before it. */
.navgroup__link { padding-right: 48px; }

/* --- 新建 ------------------------------------------------------------------ */
.topnew svg { width: 18px; height: 18px; flex: 0 0 18px; }
@media (max-width: 899px) {
  /* The bar carries the way home, one search box, and what is broken. */
  .topnew span { display: none; }
  .topnew { padding-left: 12px; padding-right: 12px; }
}

/* Seven tiles want four columns, not five. auto-fit on a 1,180px desk puts
   five on the first row and leaves two stranded beside three empty cells; four
   columns make it 4 + 3, which reads as a block rather than as a row that ran
   out. Below that the tiles find their own count. */
@media (min-width: 1100px) {
  .adminmain .stats { grid-template-columns: repeat(4, 1fr); }
}

/* On a phone a tile is a row, not a card. Stacked as cards, seven of them are
   1,400px of scrolling before the day itself — which is the 077 lesson over
   again, in a different shape. Label and number on one line, the sentence
   under it: the worklist's own layout, which was built for this screen. */
@media (max-width: 559px) {
  .stats { grid-template-columns: 1fr; gap: 8px; }
  .stat {
    display: grid; grid-template-columns: 1fr auto;
    align-items: baseline; column-gap: 12px; row-gap: 2px; padding: 10px 14px;
  }
  .stat__k { grid-column: 1; grid-row: 1; }
  .stat__v { grid-column: 2; grid-row: 1; font-size: 1.25rem; }
  .stat__d { grid-column: 1 / -1; grid-row: 2; }
}

/* --- The sittings panel opens, rather than the page jumping to it -----------
   Picking a day used to end in `#pick`, which threw the calendar off the top
   of the screen. Gan: 「can i remain the calendar - and open up at the bottom in
   an animated, then user can scroll down」. So the day links no longer carry a
   fragment, the month stays where it was, and this is how the panel underneath
   says it has changed.

   It runs on load, because the panel is server-rendered — a fresh page every
   time a day is pressed. `both` holds the opening frame before the animation
   starts, so there is no flash of the finished panel first.

   Deliberately a rise and a fade rather than an unfolding height: a real height
   animation needs the finished height up front, and the panel is anything from
   one line of 「本日没有时段」 to a whole booking form.
   ------------------------------------------------------------------------- */
@keyframes bk-open {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}
.bk-open { animation: bk-open .38s cubic-bezier(.2, .7, .3, 1) both; }

@media (prefers-reduced-motion: reduce) {
  /* Not `animation: none` — that would leave the panel on the 0% frame in a
     browser that had already started it. Ending it immediately shows the
     finished state, which is the point of the setting. */
  .bk-open { animation-duration: 1ms; }
}

/* 116 — the name at the top of a conversation opens who they are. */
.thread__name a { color: inherit; text-decoration: none; }
.thread__name a:hover, .thread__name a:focus-visible { text-decoration: underline; }

/* --- A status and a control on one row -------------------------------------
   The 预约记录 list's last cell holds the booking's state and the button that
   opens a conversation with the guest. The button is a one-button <form> — a
   POST, because opening a conversation can create the thread — and a form is a
   block, so without this it drops onto its own line and the pill floats above
   it like a mistake.
   ------------------------------------------------------------------------- */
.rowdo {
  display: flex; align-items: center; justify-content: flex-end;
  gap: 8px; flex-wrap: wrap;
}

/* ===========================================================================
   121 — 预约's day band, its week, and the columns underneath

   Gan, of the 预约 screen: 「this color and UI a bit boring... how could you
   improve it?」 — with a screenshot showing one booking, a 休息日, and the
   sentence 「没有预约」 written twice in two cards.

   The screen was not dull for want of colour. It was dull because almost
   nothing was on it, a third of it was one fact said twice, and the table
   underneath packed every column against the left and left a dead stripe
   before 状态. Nothing below introduces a colour: every value here is a token
   that was already in this file.
   =========================================================================== */

/* --- The day band ---------------------------------------------------------
   The one element on the screen that is about a *particular* day, so the one
   element that is lifted off the page and the one carrying a state edge.

   The edge is 3px, accent when the day is open and `--muted` when it is not —
   and the pill beside it says 营业 or 休息日 in words, because a colour on its
   own is not allowed to be the only thing carrying a meaning (the 30 August
   rule). Somebody who cannot tell the two greens apart reads the word.
   ------------------------------------------------------------------------- */
.dayband {
  position: relative; overflow: hidden;
  background: var(--card);
  border: 1px solid var(--line);
  border-left: 3px solid var(--line-2);
  border-radius: var(--radius-sm);
  padding: 14px 18px;
  margin-bottom: 12px;
  box-shadow: var(--sh);
}
.dayband--open { border-left-color: var(--accent); }
.dayband--shut { border-left-color: var(--muted); }

/* The leaf, the same one the public homepage draws into its corner — decoration
   in a pseudo-element, so no markup carries it and nothing is read out. The
   back office had none of the shop in it at all; this is one of the two places
   it now does. */
.dayband::after {
  content: ""; position: absolute; top: -42px; right: -46px;
  width: 168px; height: 168px; 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");
}
.dayband > * { position: relative; }

.dayband__head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
}
.dayband__when { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* The date is the largest figure on the screen and it is the one thing here
   that is genuinely a number, so it keeps the figures. */
.dayband__date {
  font-family: var(--mono); font-size: 1.45rem; font-weight: 500;
  font-variant-numeric: tabular-nums; letter-spacing: -.01em;
}
/* The weekday is not a number and does not line up with anything. */
.dayband__dow { color: var(--ink-2); font-size: 1rem; }
.dayband__nav { display: flex; gap: 6px; }
.dayband .caps { margin-top: 14px; }
.dayband__note { margin: 12px 0 0; color: var(--muted); }

/* The next open day. A row on a wide screen, a stack on a phone — and the
   button is a real one, because it is the answer to the question the shut day
   raises. */
.dayband__next {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line);
}
.dayband__nextlabel { color: var(--muted); }
.dayband__nextday { display: flex; align-items: baseline; gap: 8px; }
.dayband__nextday .mono { font-size: 1.1rem; font-variant-numeric: tabular-nums; }
.dayband__free { color: var(--muted); font-variant-numeric: tabular-nums; }
.dayband__next .btn { margin-left: auto; }

/* --- 这一周 --------------------------------------------------------------
   Seven days across, Monday first like every other calendar here since 109.

   `repeat(7, minmax(0, 1fr))` and not `auto-fit`: a week is seven days at every
   width, and a strip that reflows to four and three is no longer a week. At
   390px each cell is about 46px, which is why the weekday is the short form and
   the count sits under the number rather than beside it.
   ------------------------------------------------------------------------- */
.weekstrip__head {
  display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
  margin-bottom: 12px;
}
.weekstrip__span { color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; }
.weekstrip__sum { margin-left: auto; color: var(--muted); font-size: .875rem; }
.weekstrip__days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8px; }

.wday {
  display: flex; flex-direction: column; gap: 6px;
  min-height: 92px; padding: 10px 8px;
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  background: var(--card); color: inherit; text-decoration: none;
}
.wday:hover { border-color: var(--line-2); background: var(--soft); }
.wday:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* **.875rem is the floor, not a suggestion.**
   The first pass had these at .8125rem and .6875rem — 13px and 11px — and
   `tests/legibility.php` refused all three. The 30 August work set a 14px
   floor for anything carrying meaning, and a weekday, a booking count and the
   shop's own locality all carry meaning. The strip was resized around the type
   rather than the other way round: seven cells at 390px is about 46px each,
   which fits 四 and 0 笔 on two lines at .875rem. */
.wday__dow { color: var(--muted); font-size: .875rem; }
.wday__n {
  font-family: var(--mono); font-size: 1.15rem;
  font-variant-numeric: tabular-nums; line-height: 1.1;
}
.wday__bar { height: 6px; border-radius: 999px; background: var(--soft); overflow: hidden; }
.wday__fill { display: block; height: 100%; background: var(--celadon); }
.wday__fill--full { background: var(--red); }
.wday__note { margin-top: auto; color: var(--muted); font-size: .875rem; }
/* A day with something in it says so in the accent — and also in the number,
   which is what a reader who cannot see the difference goes by. */
.wday__note--on { color: var(--accent); }

/* A closed day is drawn closed rather than left looking empty. Dashed, and it
   carries the word — the one thing a row of blank cells cannot distinguish is
   「shut」 from 「open and quiet」, which on this screen is the whole question. */
.wday--shut { border-style: dashed; background: transparent; }
.wday--shut .wday__n { color: var(--muted); }
.wday__bar--none { background: transparent; }

.wday--past { opacity: .62; }
/* The day being looked at, and today, are two different facts and are marked
   two different ways: the viewed day is filled and ringed, today wears a rule
   under its number. A Tuesday you are viewing on a Tuesday shows both. */
.wday--picked { background: var(--soft); border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.wday--today .wday__n { box-shadow: inset 0 -2px 0 0 var(--line-2); }

/* --- 预约记录's columns ---------------------------------------------------
   Fixed tracks so the row stops packing left. 留言 takes `auto` and therefore
   everything the others do not want, because the note is the only cell whose
   width is not known in advance; 状态 is 1% and `nowrap`, which is the way to
   say 「as narrow as the pill and no narrower」 in a fixed-layout table.

   Behind a min-width query on purpose: below 560px `.cardtable` stops being a
   table at all, and a width on a cell that is `display: block` is noise at best.
   ------------------------------------------------------------------------- */
/* **`width: 1%` does not mean 「as narrow as the content」 here — 121a.**
   ---------------------------------------------------------------------------
   It does in an `auto` table, which is where that trick comes from. Under
   `table-layout: fixed` the browser never looks at the content: 1% is one per
   cent of the table, about twenty pixels, and 已确认 plus 对话 spilled out of
   it and off the right-hand edge of the card. Gan's screenshot, immediately
   after the release.

   So the last column is given the width it actually needs, measured from what
   is in it: a status pill and a 44px-target button side by side. Every other
   track is measured the same way — the longest real value in it, plus the
   24px of cell padding, which `box-sizing: border-box` puts inside the number.

   **Not in rem**, and `tests/legibility.php` is what said so: the admin root is
   112.5%, and the rule that lets it be is that nothing but *type* is measured
   in rem.

   **`min-width` and let it scroll, rather than a breakpoint.** The six fixed
   tracks add up to 772px and 留言 wants about 114 on top, so 886 is the width
   at which this table is itself. Given that as a floor, `.table-scroll` — which
   exists for exactly this — carries the rest: the columns keep their shape at
   every width and a narrow window scrolls sideways *inside the card*, with the
   page itself never scrolling.

   The alternative, tried and measured and worse, was to let the auto layout
   run below a breakpoint. At 900px it squeezed 客人 to 58px and 编号 to 53,
   both wrapped, and every row went from 54px to 121 — while still overflowing
   by 175. Numbers from Chromium at 390 / 700 / 900 / 1199 / 1200 / 1290 /
   1440; rows are 54px at all of them now, and 留言 gets 114px at 1200, 204 on
   the 1290 screen this is run from, and 354 on a 1440. */
@media (min-width: 560px) {
  .cardtable--book { table-layout: fixed; width: 100%; min-width: 966px; }
  /* Each width is the widest real value in that column, measured in the
     browser, plus the 24px of cell padding that `box-sizing: border-box` puts
     inside the number. The first pass eyeballed them and 编号 came out at
     exactly its content width — 88px of text in 88px of room — so
     R-202610-002 wrapped onto two lines in every row. The second pass gave it
     124 and the browser then ellipsed it at 「R-202610-002…」, five pixels
     short: a measurement taken before the cell was told not to wrap is a
     measurement of a different cell. These are the third set, taken after. */
  .cardtable--book th:nth-child(1), .cardtable--book td:nth-child(1) { width: 216px; }  /* 01/10/2026 14:00 星期四 = 187 */
  .cardtable--book th:nth-child(2), .cardtable--book td:nth-child(2) { width: 112px; }
  .cardtable--book th:nth-child(3), .cardtable--book td:nth-child(3) { width: 160px; }  /* +60 17-319 4195 = 132 */
  .cardtable--book th:nth-child(4), .cardtable--book td:nth-child(4) { width: 52px; }
  .cardtable--book th:nth-child(5), .cardtable--book td:nth-child(5) { width: auto; }
  .cardtable--book th:nth-child(6), .cardtable--book td:nth-child(6) { width: 136px; }  /* R-202610-002 = 105 */
  .cardtable--book th:nth-child(7), .cardtable--book td:nth-child(7) { width: 176px; }  /* 已确认 + 对话 = 144 */

  /* A name, a number and a reference are one line each. Without this a fixed
     track wraps them instead of overflowing, and one long name takes every row
     in the table to double height. */
  .cardtable--book td:nth-child(2),
  .cardtable--book td:nth-child(3),
  .cardtable--book td:nth-child(6) {
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }

  /* A fixed table clips rather than widens, so the note has to be told what to
     do with what will not fit. It is already trimmed to 44 characters in PHP
     and carries the whole of itself in `title`; this is the second line of
     defence for a long single word. */
  .cardtable--book td:nth-child(5) { overflow: hidden; text-overflow: ellipsis; }
}

/* **The pill and the 对话 button stay on one line while this is a table.**
   `.rowdo` wraps by design — it has to in a stacked card on a phone. In a table
   row a wrap is the button dropping underneath the pill, which takes the row
   from 54px to 120px and, in a track sized for exactly these two, puts it over
   the row below. That is the other half of what Gan's screenshot showed.
   Measured at 900px: 120px rows before, 54px after. */
@media (min-width: 560px) {
  .cardtable--book .rowdo { flex-wrap: nowrap; }
}

/* --- The shop signs its own back office -----------------------------------
   The rail was six words and a lot of dark green. 老葉的児子 at its foot, over
   the letterspaced line the homepage already uses.

   `--brand`, not `--serif`: `--serif` has followed `--sans` exactly since 098,
   so the name set in it would be drawn in the same face as the navigation
   above it and there would be no mark at all. `--brand` is the one stack in
   this file carrying 葉 and 児 — which is why it survived that change, and why
   it is the right token here.

   Decoration, and the whole of it is `aria-hidden`: the shop's name is already
   in the top bar and in the footer, and a screen reader does not need it a
   third time on the way past the navigation.
   ------------------------------------------------------------------------- */
.railfoot {
  margin-top: auto; padding: 14px 10px 0;
  border-top: 1px solid var(--rail-2);
}
.railfoot__name {
  font-family: var(--brand); font-size: 1.25rem;
  color: var(--rail-ink); line-height: 1.2;
}
.railfoot__caps {
  /* Uppercased here and not in the data, exactly as `.hero__caps` does it on
     the homepage: the setting holds 「Leaf Lohas Lifestyle」 as somebody typed
     it, and a shop whose Latin name is properly mixed-case should not have to
     retype it in capitals to get this line. Looser tracking than the hero's
     .22em because the rail is 236px wide and the line has to fit on one. */
  margin-top: 4px; font-family: var(--mono); font-size: .875rem;
  letter-spacing: .1em; text-transform: uppercase; color: var(--rail-dim);
}
/* The rail is a column so that the foot can be pushed to the bottom of it.
   ---------------------------------------------------------------------------
   **Inside a `min-width` query, and that is the whole point of the rule.**
   `.sidebar { display: none }` lives in `@media (max-width: 759px)` six
   hundred lines above. A media query adds nothing to specificity, so a bare
   `.sidebar { display: flex }` written down here — later in the file, same
   weight — would simply beat it, and the rail would come back on every phone
   underneath the bottom bar it was replaced by. Caught by rendering at 390px,
   not by reading. */
@media (min-width: 760px) {
  .sidebar { display: flex; flex-direction: column; }
  .sidebar .adminnav { flex: 0 0 auto; }
}

/* --- A reaction, drawn as a reaction — 122 --------------------------------
   Gan: 「evon gan reply with emoticon.. but i cannot see」. The webhook fix is
   most of it — a reaction arrived as the literal word `reaction` — but an emoji
   set in body text inside a full-width bubble still reads as a message that
   failed to load. So: no bubble to speak of, and large enough to be the point
   of the line rather than a typo in it.

   The timestamp stays at its usual size, because a reaction has to be placeable
   in the conversation and 13:44 in 30px would be shouting.
   ------------------------------------------------------------------------- */
.chat__bubble--react {
  background: transparent;
  border-color: transparent;
  padding: 2px 4px;
  max-width: none;
}
.chat__bubble--react .chat__body {
  font-size: 1.9rem; line-height: 1.15;
}
.chat__bubble--react .chat__meta { margin-top: 2px; }

/* --- Pull down to reload, on the home-screen app only — 127 ----------------
   Gan, of the iPhone icon: 「i cannot reload.. swipe up does not work」.

   A home-screen web app has no address bar, so no reload button, and iOS does
   not give a standalone app the pull-to-refresh its browser has. The gesture
   is `app.js`'s; this is the pill it shows, which is the only thing telling
   the operator that a pull is going to do something.

   Clear of the notch: `top` is the safe-area inset plus a little, so on a
   phone with a notch the pill starts below it rather than under it.
   ------------------------------------------------------------------------- */
.pullr {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 8px);
  left: 50%;
  z-index: 60;
  width: 42px; height: 42px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 999px;
  background: var(--card);
  border: 1px solid var(--line-2);
  box-shadow: var(--sh);
  color: var(--muted);
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, 0);
}
.pullr svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }

.pullr.is-on { opacity: 1; }

/* Far enough to count. The colour changes *and* the ring fills, because a
   change of colour alone is not allowed to be the whole signal here either. */
.pullr.is-ready {
  color: var(--on-accent);
  background: var(--accent);
  border-color: var(--accent);
}

.pullr.is-going { animation: pullr-spin .9s linear infinite; }

@keyframes pullr-spin {
  to { transform: translate(-50%, 24px) rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
  .pullr.is-going { animation: none; }
}
