:root {
  /* --site-bg is written by src/theme.js from the chosen palette colour, so
     every opaque surface built on --color-bg (page fill, editor side panel,
     dialogs, the storage lightbox) follows the palette instead of being
     pinned to one near-black. The literal here is only the pre-manifest
     first paint. */
  --color-bg: var(--site-bg, #0d0d12);
  --color-surface: rgba(255, 255, 255, 0.06);
  --color-surface-strong: rgba(255, 255, 255, 0.1);
  --color-border: rgba(255, 255, 255, 0.12);
  --color-text: #f3f2ef;
  --color-text-muted: rgba(243, 242, 239, 0.62);
  --color-accent: #e8b34d;
  --color-accent-strong: #f4c565;
  --font-display: Georgia, "Noto Serif TC", "PMingLiU", serif;
  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang TC", "Microsoft JhengHei", sans-serif;
  /* Motion scale. Every duration in the app resolves to one of these four so
     a hover and a panel opening cannot drift into unrelated speeds — which is
     what happens once each rule picks its own number. Named by intent rather
     than by milliseconds so changing the feel is one edit here.
     Written from scratch: transitions.dev is the reference for WHICH
     interactions deserve motion, not a source of code — that repo ships no
     licence, so nothing is copied from it. */
  --dur-instant: 90ms;   /* press feedback; must not be perceptible as a delay */
  --dur-quick: 170ms;    /* hover, selection, small state swaps */
  --dur-base: 260ms;     /* things that move a visible distance */
  --dur-slow: 420ms;     /* panels and sheets crossing the screen */

  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  /* Gentler than expo for short distances, where expo reads as an abrupt stop. */
  --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
  /* Overshoots ~10%. For something ARRIVING (a number changing, a badge
     appearing) — never for something leaving, where the bounce reads as a
     glitch. */
  --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
  /* Three radii, nothing else (design/portfolio-style-v2.pen): controls,
     cards/panels, pills. They used to be 14/22, which read as one soft blur
     across the whole UI with no distinction between a button and a panel. */
  --radius-md: 10px;
  --radius-lg: 16px;
  --radius-pill: 999px;
  /* Traditional Chinese is the primary language here. CJK needs noticeably
     more leading than Latin at the same size, and the old default left every
     wrapped label looking cramped. Display type overrides this downward. */
  --line-body: 1.7;
}

/* Dark-on-light token set, switched on by src/theme.js when the chosen site
   background colour is light. Only the tokens that are painted ON the
   background move; the accent stays put because it is used as a fill under
   dark text (the gold buttons) as often as it is used as text itself, and
   flipping it would break those.
   --color-bg is deliberately NOT restated here: it already follows --site-bg,
   i.e. the actual chosen colour, which is lighter than these tokens by
   definition whenever this block applies. */
:root.is-light-bg {
  --color-surface: rgba(0, 0, 0, 0.06);
  --color-surface-strong: rgba(0, 0, 0, 0.11);
  --color-border: rgba(0, 0, 0, 0.16);
  --color-text: #17161c;
  --color-text-muted: rgba(23, 22, 28, 0.65);
  /* The gold is legible as TEXT on a dark page and invisible on a light one —
     it carries the storage page's 未使用 counts, which are the whole reason
     that page exists. The two places that use it as a FILL under dark text
     (the gold buttons' hover) pin their own colour; see .upload-primary-btn
     and .viewer-nav__edit. */
  --color-accent-strong: #8a5a00;
}

* {
  box-sizing: border-box;
}

html, body {
  margin: 0;
  padding: 0;
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-ui);
  overflow-x: hidden;
}

body {
  min-height: 100vh;
  line-height: var(--line-body);
}

a {
  color: inherit;
  text-decoration: none;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
  cursor: pointer;
}

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

#app {
  position: relative;
  min-height: 100vh;
}

.loading-state {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--color-text-muted);
  font-family: var(--font-display);
  font-size: 1.1rem;
}

/* Shared by every MediaLayer surface (see src/components/MediaLayer.js). */
.media-layer--fill {
  width: 100%;
  height: 100%;
}

.media-layer__empty {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--color-text-muted);
  font-size: 1.5rem;
}

/* Motion vocabulary — the state changes that currently happen instantly.
   Hand-written; see the note on the duration scale above.

   Every effect below is opt-in via a class, so nothing moves that was not
   asked to move, and all of them collapse under prefers-reduced-motion at the
   bottom of this block rather than each carrying its own guard. */

/* A value that ARRIVED. Used where a number is replaced by a different number
   — storage used, view counts — because otherwise the figure changes with no
   evidence anything happened and the eye misses it entirely. */
@keyframes t-pop {
  from { opacity: 0; transform: scale(0.86); }
  to   { opacity: 1; transform: scale(1); }
}

.t-pop { animation: t-pop var(--dur-base) var(--ease-spring) both; }

/* One label replacing another in place: 儲存 → 儲存中… → 已儲存.
   The outgoing word leaves upward and the incoming one rises into its place,
   so the two read as the same label changing rather than as two labels. */
@keyframes t-swap-in {
  from { opacity: 0; transform: translateY(0.45em); }
  to   { opacity: 1; transform: translateY(0); }
}

.t-swap { animation: t-swap-in var(--dur-quick) var(--ease-out-quart) both; }

/* Rejection. Small and fast — a big shake reads as breakage rather than as
   "that was wrong, try again". Ends exactly where it started. */
@keyframes t-shake {
  0%, 100% { transform: translateX(0); }
  15%      { transform: translateX(-5px); }
  30%      { transform: translateX(4px); }
  45%      { transform: translateX(-3px); }
  60%      { transform: translateX(2px); }
  80%      { transform: translateX(-1px); }
}

.t-shake { animation: t-shake 380ms var(--ease-out-quart) both; }

/* A tick that draws itself. dasharray/offset on the path is what makes it read
   as being drawn rather than fading in. */
@keyframes t-check-draw {
  from { stroke-dashoffset: 22; }
  to   { stroke-dashoffset: 0; }
}

.t-check path {
  stroke-dasharray: 22;
  animation: t-check-draw var(--dur-base) var(--ease-out-quart) both;
}

/* Something appearing beside content that is already there — a badge, a count.
   Scales from its own corner so it reads as attached to what it marks. */
@keyframes t-badge {
  from { opacity: 0; transform: scale(0.4); }
  to   { opacity: 1; transform: scale(1); }
}

.t-badge {
  transform-origin: 100% 0;
  animation: t-badge var(--dur-quick) var(--ease-spring) both;
}

/* Press feedback. A control that does not acknowledge the touch feels broken
   on a phone, where there is no cursor and no hover to fall back on. */
.t-press { transition: transform var(--dur-instant) var(--ease-out-quart); }
.t-press:active { transform: scale(0.96); }

/* One guard for the whole vocabulary. Reduced motion means no movement — but
   the element must still END in its final state, so the animations are kept
   and collapsed to a single frame rather than removed, which would leave
   anything relying on `both` stuck at its from-state and invisible. */
@media (prefers-reduced-motion: reduce) {
  .t-pop, .t-swap, .t-shake, .t-badge, .t-check path {
    animation-duration: 1ms;
    animation-iteration-count: 1;
  }

  .t-press { transition: none; }
  .t-press:active { transform: none; }
}
