:root {
  --brand: #1F2BD4;
  --brand-deep: #161FA0;
  --sidebar-brand: #1F2BD4;
  --sidebar-brand-deep: #161FA0;
  --brand-bright: #4A55E8;
  --brand-weak: #EEF0FE;
  /* 认证蓝勾的勾形。viewBox 24x24, 描边加圆头之后墨迹 x 2.8-21.2 / y 4.9-19.4,
     两个方向都居中, 所以 mask 的 contain 缩放不会把它挪偏。 */
  --verified-tick: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4.5 12.6 L9.6 17.7 L19.5 6.6' fill='none' stroke='%23000' stroke-width='3.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  /* The school's colour on a large accent line over a LIGHT page. Separate from
     --brand-bright, which is tuned against dark chrome and is unreadable on
     paper for an accent-brand school (UNSW yellow at 1.37:1), and from --brand,
     which for a navy school reads as the same ink as the headline it sits
     between. Defaults to --brand; see scripts/build.mjs. */
  --hero-accent: #1F2BD4;
  /* The identity colour and the ink that stays legible on it. Separate from
     --brand because --brand has to carry large surfaces under white text, so a
     school whose identity colour is an accent (UNSW yellow, ANU gold) keeps its
     chrome dark and shows the real colour here — on the brand mark tile. For
     unimelb/monash these are just --brand + white, i.e. no visual change. */
  --brand-mark: #1F2BD4;
  --brand-mark-ink: #FFFFFF;
  /* The tile's ring. It exists because the tile is normally the same colour as
     the block behind it — the ring is what makes the mark visible at all. A
     school that gives the tile its own contrasting fill does not need it and
     sets this to the fill colour to hide it. */
  --brand-mark-border: #161FA0;
  /* The topbar is the one large surface a school can paint in an accent colour,
     because unlike the sidebar it holds only a handful of controls. Its ink has
     to travel with it — a yellow bar with white buttons is unreadable — so the
     foreground is a token too, and every control in .topbar reads it. Defaults
     are the old hardcoded values, so a tenant that sets none of these renders
     exactly as before. */
  --topbar-bg: #141A3C;
  --topbar-ink: #FFFFFF;
  --topbar-line: #0A0D24;
  /* Same idea one column left. The sidebar is written as "dark surface, white
     ink" — labels, hovers, borders and the selected pill were all hardcoded
     white — so a school that wants a light sidebar needs the ink to travel with
     the surface. The active pill carries its own pair because it inverts
     (light pill on dark → dark pill on light), which the ink alone cannot say.
     Defaults reproduce the old hardcoded values exactly. */
  --sidebar-ink: #FFFFFF;
  --sidebar-active-bg: #FFFFFF;
  --sidebar-active-ink: #1F2BD4;
  /* Filled buttons. Separate from --brand because --brand also colours text on
     white content pages: a school can want a light, saturated button fill
     (which needs dark labels) without dragging its body links to the same
     unreadable colour. Defaults are what the button rules hardcoded. */
  --button-bg: #1F2BD4;
  --button-ink: #FFFFFF;
  --button-bg-hover: #161FA0;
  /* The content area's strong ink — card titles, headings, filled chips, the
     like icon. It is a brand-TINTED near-black, not the neutral --text, and it
     was hardcoded to UniMelb's navy in 209 places, which is why everything
     inside the page stayed UniMelb-coloured while the chrome followed the
     tenant. Defaults keep UniMelb exactly as it was. */
  /* Dark mode's chrome (topbar + sidebar) and canvas (the page behind the
     cards), templated per tenant by scripts/build.mjs from its DARK_SCHEMES
     table — "cool" for the blue/violet schools, "warm" for the yellow/orange/
     gold ones. See that table for why the split is per brand TEMPERATURE.

     They live in :root, not in [data-theme="dark"] below, on purpose:
     darkReaderBoot.js reads both off a getComputedStyle probe that runs before
     the engine is enabled and before <body> is parsed, so the declaration has
     to be unconditional. Declaring them here costs light mode nothing — no
     light-mode rule reads either one. */
  --dark-chrome: #0f1011;
  --dark-canvas: #16181c;
  /* The surface family — the fill behind cards, panels and chips. Rotated onto
     each school's brand hue by build.mjs's darkNeutralScale(); see that function
     for why these are captured from Dark Reader's own derivation rather than
     authored, and for the one-variable lever that makes them applicable at all.
     Consumed by darkReaderBoot.js's surfaceVarFix(), never by a CSS rule here. */
  --dark-surface: #131316;
  --dark-surface-2: #1a1a1e;
  --dark-surface-3: #222226;
  --dark-surface-muted: #1a1a1e;
  --dark-ink-strong-bg: #1a1a1e;
  /* Solid fill for the selected sidebar row in dark mode, or the literal "none"
     to let darkReaderBoot derive the usual translucent wash off --brand-bright.
     See activeAccentFix() for when a school needs to override it. */
  --dark-active-bg: none;
  /* Dark-mode fill for the primary action button, or "none" to derive it from
     --brand-bright. See primaryButtonBg() in darkReaderBoot.js. */
  --dark-button-bg: #4A55E8;
  --ink-strong: #12173A;
  --ink-strong-deep: #0A0D24;
  /* Templated per tenant by scripts/build.mjs (neutralScale). These are not
     really neutral — every one of them carries a hue, and it used to be
     UniMelb's navy for all eight schools. Each now carries its own brand's,
     at the same saturation and lightness. --surface stays pure white: a card
     is a sheet of paper on the tinted canvas, not another tint. */
  --canvas: #f5f5fb;
  --surface: #ffffff;
  --surface-muted: #f0f0f6;
  --line: #e4e5ef;
  --text: #20212c;
  --muted: #686a82;
  --soft: #8a8ca6;
  /* "Pass-through" tokens (quick/260825-6v5): each one's light value is
     byte-identical to a literal app.css still hardcodes in dozens of places,
     so swapping the literal for var(--x) is a no-op in light mode. They exist
     only so the dark-mode override block further down has something to
     redirect. */
  --surface-2: #f8fafc;
  --surface-3: #eef1f6;
  --line-strong: #d9dfeb;
  --line-soft: #e5eaf2;
  /* quick/260826-mcq: --line-card is the fifth pass-through token. It is the
     feed card container's own hairline — the .masonry-feed outer border and
     the .post-card row divider share it because the original author made
     them the same colour and weight on purpose (one continuous "card
     boundary" line, not two independently-tunable ones). It is kept
     separate from --line (not merged into it) because in light mode the two
     are NOT the same colour — --line is a blue-tinted #e4e8ef, this literal
     is a neutral #d5d5d5 — so routing these two borders through --line would
     visibly change light mode, which this migration must not do. */
  --line-card: #d5d5d5;
  /* --ink-strong plays two roles across app.css: TEXT colour (161 sites) and
     FILL/BORDER/SHADOW colour (93 sites — dark chips/badges with white text
     painted on top). In light mode both roles resolve to the same literal
     (hence the same build-time palette placeholder as --ink-strong / -deep
     above), but dark mode has to send them in opposite directions —
     near-white for text, still-dark for fills — so one token cannot carry
     both. This is the fill/border/shadow half. */
  --ink-strong-bg: #12173A;
  --ink-strong-deep-bg: #0A0D24;
  --danger: #9d2735;
  --radius-ui: 4px;
  --radius-sm: 8px;
  --radius-md: 12px;
  --sidebar-width: 264px;
  --topbar-height: 68px;
  /* iOS safe-area insets (0px in any normal browser / non-notched device, so
     every calc(x + var(--safe-*)) below is a no-op off-device). Only the
     Capacitor shell with viewport-fit=cover makes these non-zero. */
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-right: env(safe-area-inset-right, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  --safe-left: env(safe-area-inset-left, 0px);
  --focus: 0 0 0 3px color-mix(in srgb, var(--brand) 22%, transparent);
  /* UI easing curves (Emil Kowalski): ease-out for enter/exit + press feedback
     (starts fast = feels responsive), ease-in-out for on-screen moves. Never
     ease-in for UI — it delays the first frame users watch most. */
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
  --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
  color-scheme: light;
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

/* Dark mode (quick/260826-3tj) is no longer implemented as a hand-rolled CSS
   overlay here. It is now driven at runtime by the Dark Reader engine
   (src/vendor/darkreader.js, booted by src/config/darkReaderBoot.js), which
   repaints every surface from its actual computed style rather than
   requiring each component to be pre-token-ized into a manual dark palette.
   The brand-mark tenant-colour carve-out (D-02) that used to live here as
   "these three tokens are deliberately left undeclared" is now implemented
   by the boot script's generated Dark Reader fixes.css exemption instead —
   see darkReaderBoot.js for the mechanism. This block also used to flip
   color-scheme to its dark keyword; that declaration is gone too — Dark
   Reader's styleSystemControls option now owns form controls and the
   scrollbar. */

/* quick/260826-mcq: content-area Apple HIG dark palette. This block is a
   PARTIAL, DELIBERATE reversal of the paragraph above — it reverts ONLY the
   content-area neutral tokens listed below to explicit values. Chrome
   (sidebar/topbar/brand-mark/buttons) and --danger are still 100% derived by
   the Dark Reader runtime engine per-tenant; that is the explicit e5a/f2v
   user decision and this block must never add a --brand* / --sidebar-* /
   --topbar-* / --button-* / --hero-accent / --danger / --focus / --radius* /
   --safe-* / --ease-* declaration, nor a color-scheme declaration (see the
   MAINTENANCE line at the bottom of this block).

   WHY: live measurement this session showed Dark Reader's per-tenant
   compression algorithm collapsing document.body's var(--canvas) and
   .masonry-feed's var(--surface) to the SAME flat grey rgb(24, 26, 27) — the
   page and the card container had zero depth between them. Rather than keep
   guessing at ad-hoc fixes, these 16 tokens were originally pinned to Apple's
   public UIKit dynamic system colour constants (iOS 17 / macOS Sonoma dark
   appearance).

   quick task (260827): superseded by Microsoft Fluent 2's dark neutral scale,
   on an explicit user decision to model this project's dark mode on Fluent 2's
   own docs site (fluent2.microsoft.design) instead of Apple HIG. The starting
   point was a live `getComputedStyle` measurement of that site
   (https://fluent2.microsoft.design/design-tokens): page `body` background
   #1f1f1f, an elevated content box's fill #292929, a rendered `<hr>`'s border
   colour #3d3d3d.

   Those exact numbers could NOT be used as-is, for a reason specific to this
   codebase: --canvas and --surface are consumed at dozens of `background:
   var(--surface)` sites across app.css (far too many to individually pin via
   darkReaderBoot.js's fixes.css mechanism — see that file's own extensive
   notes on why var()-consumption sites need explicit pins). Only html/body's
   `var(--canvas)` consumption is pinned (canvasFix); every other --surface
   consumer is left to Dark Reader's own engine derivation, which independently
   converts :root's LIGHT-mode value (--surface: #ffffff) to a FIXED dark
   output measured at rgb(24, 26, 27) / #181a1b, regardless of what this block
   declares. Adopting Fluent's literal #1f1f1f for --canvas while leaving
   --surface's real rendered floor at #181a1b would have INVERTED the intended
   layering (canvas lighter than the cards sitting on top of it) — measured
   live and caught before landing. --canvas below is therefore pinned darker
   than that #181a1b floor instead of at Fluent's literal value, and --surface
   is written as #181a1b — Dark Reader's actual fixed output, not a new
   editorial choice — with the rest of the ladder (--surface-2/-3/-muted,
   --ink-strong-bg/-deep-bg) stepped up from that real floor in Fluent-sized
   increments. --line/--line-card keep Fluent's literal measured #3d3d3d
   because CONTENT_LINE_SELECTORS pins those two specific selectors directly
   (bypassing the engine entirely), so their real rendered colour IS
   guaranteed to match what's declared here — unlike the surface ladder above.
   --muted/--soft dropped Apple's blue-tinted rgba base (235, 235, 245) for a
   true neutral (255, 255, 255) base, matching Fluent's true-grey (not
   hue-tinted) neutral scale.

   SHARED ACROSS ALL 8 TENANTS: this block writes literal hex/rgba values,
   not double-curly-brace build-time placeholders, so build.mjs's per-tenant
   template substitution does not touch it — every tenant gets the identical
   palette.
   This is intentional, continuing D-01 ("dark mode is one shared palette
   across tenants"), not an oversight.

   AMOLED ALSO GETS THIS BLOCK: index.html's pre-paint probe and
   src/config/theme.js only ever write data-theme="dark" or omit the
   attribute entirely — the three-way light/dark/amoled distinction lives
   purely in the JS field window.__wallTheme.appearance, deliberately kept
   out of the DOM attribute (5kj decision, to avoid doubling this selector's
   maintenance surface with a third attribute value). Consequence: this
   [data-theme="dark"] block is ALSO active when the user has chosen AMOLED,
   so .masonry-feed goes from pure black to --surface's #1c1c1e charcoal
   under AMOLED too. This is an accepted, verified outcome (matches
   real-world "true black" reference apps, where card containers stay a
   slightly-elevated charcoal even though the base canvas is pure black), not
   a bug to route around.

   DARK READER STILL REPAINTS THESE VALUES: the engine's dynamic pass
   processes every colour it finds, including the literals declared here — it
   does not skip custom-property definitions just because they resolve to an
   already-dark value. Because every input here is already very dark, the
   engine's own background-compression behaviour (MAX_BG_LIGHTNESS = 0.4,
   verified against the vendored engine source this session) COMPRESSES
   rather than inflates: the relative step between --canvas / --surface /
   --surface-2 / --surface-3 survives, only the absolute lightness is pulled
   down slightly. That is known, acceptable, and was verified live before
   this block was written — the acceptance bar is "the layer step is visible
   and the page looks like Apple's own dark UI", not "the computed value is
   byte-identical to the literal below". */
[data-theme="dark"] {
  /* Pinned via darkReaderBoot.js's canvasFix() — guaranteed to render exactly
     this value. Chosen to sit below --surface's #181a1b engine-fixed floor
     (see this block's header comment), not at Fluent's literal #1f1f1f. */
  --canvas: var(--dark-canvas); /* quick task (260828): was the hardcoded #16181c
     (itself superseding #0f1011/#141414). Now reads the per-tenant token above so
     this declaration and darkReaderBoot.js's CANVAS_DARK pin — which reads the
     same custom property — can never name two different colours. */
  /* NOT independently pinned — this is Dark Reader's own fixed engine
     derivation of :root's --surface: #ffffff (measured live, coincides
     numerically with darkReaderBoot.js's CHROME_DARK constant). Written here
     for documentation/future-pin-reference, not as a value that takes effect
     at most of --surface's dozens of call sites. */
  --surface: #181a1b;
  --surface-2: #232323; /* one step up from --surface's real floor, Fluent-sized increment */
  --surface-3: #2d2d2d; /* two steps up */
  /* --surface-muted: 31 of its call sites (app.css) are panels/chips/avatar
     fills/skeleton bases nested INSIDE a --surface card. Taking the same
     value as --surface would make all 31 disappear into the card background,
     so this takes the next step up instead (matches --surface-2) — also the
     literal darkReaderBoot.js's tagChipFix() pins TAG_CHIP_BG_DARK to,
     making this one specific consumer pair (.tag-row span/.flag/.edited)
     guaranteed real rather than aspirational. */
  --surface-muted: #232323;
  /* quick task (260827-x9d): 正文墨色从纯白降到 #e8e8e8, 并且与 --ink-strong 分家。
     mcq 当初把两者一起定成纯白, 依据是 Fluent2 文档站量到的"正文/标题文字色" —— 那
     个量法把两种角色混成了一个值。实际效果是整页每一个字都是 18.3:1 的纯白(实测帖子
     详情弹窗), 长中文段落在近黑底上会发晕(halation), 而且标题和正文一样亮, 层次全平。
     #e8e8e8 仍有 ~15:1, 远高于任何无障碍门槛; 标题保留纯白, 于是"标题比正文亮"这个
     层次第一次真的存在。引擎那一侧的对应旋钮是 darkReaderBoot.js 的
     darkSchemeTextColor, 两者必须同步改, 否则 token 迁移过的元素和没迁移的会分叉
     （f2v 那次的 #c4ccc6 就是这么裂开的）。 */
  --text: #e8e8e8;
  --ink-strong: #ffffff;
  --ink-strong-deep: #ffffff; /* no "more white" than white exists */
  --muted: rgba(255, 255, 255, 0.6); /* true-neutral base (Fluent's grey scale carries no hue tint, unlike Apple's secondaryLabel) */
  --soft: rgba(255, 255, 255, 0.3);
  /* --line/--line-card: pinned via darkReaderBoot.js's CONTENT_LINE_SELECTORS
     (.masonry-feed, .post-card) — guaranteed to render exactly this value at
     those two selectors, unlike the surface ladder above. Fluent2 docs site:
     measured <hr> border colour. */
  --line: #3d3d3d;
  --line-card: #3d3d3d; /* deliberately converges with --line in dark mode */
  --line-strong: #444444; /* one step above --line, same picking-a-visible-step pattern as before */
  --line-soft: #181a1b; /* matches --surface's real floor */
  --ink-strong-bg: #232323; /* matches --surface-2 (fill role) */
  /* --ink-strong-deep-bg: all 16 call sites (app.css) are focus rings / active
     borders / selected-state fills — "stronger than resting state" by
     definition. Taking --surface-3's value (one step above --ink-strong-bg's
     --surface-2) gives resting vs. selected/focused a visible step. */
  --ink-strong-deep-bg: #2d2d2d; /* matches --surface-3 (selected/focus fill role) */
} /* MAINTENANCE: never add --brand* / --sidebar-* / --topbar-* / --button-*
     / --hero-accent / --danger / --focus / --radius* / --safe-* / --ease-*
     or color-scheme inside this block — those are chrome/brand (Dark Reader
     per-tenant territory per e5a/f2v) or unrelated concerns; color-scheme is
     owned by the engine's styleSystemControls option (3tj removed it once,
     do not bring it back here). */

* {
  box-sizing: border-box;
}

html {
  background: var(--canvas);
}

body {
  margin: 0;
  background: var(--canvas);
  color: var(--text);
  /* Make the body's own box cover the viewport, so the canvas colour is the
     canvas colour all the way down even when the page has little content.

     This is not cosmetic tidying — it is the only fix available for a real
     dark-mode bug, and the reason why is worth writing down because nothing
     else in this codebase has hit it yet.

     Symptom: on a short page in dark mode the top of the viewport was the
     pinned canvas (#1c1c1e on a warm tenant) and everything below the body's
     780px was #181a1b — the page looked like it had two different backgrounds
     stacked. That lower half is <html>'s background showing through where
     <body> does not reach.

     Why darkReaderBoot.js's pin cannot fix it: CANVAS_SELECTORS pins BOTH
     "html" and "body", and both rules really are emitted — the html one reads
     `html { background: rgb(28, 28, 30) !important; }` in the engine's
     override sheet, verified live. The body pin lands. The html pin does not,
     and no amount of specificity changes that, because the engine's
     user-agent sheet wraps its own `html { background-color: … !important }`
     in an anonymous CASCADE LAYER (`@layer { … }`).

     For !important declarations the cascade REVERSES layer order and puts
     UNLAYERED important declarations LAST — i.e. weakest. Every rule
     darkReaderBoot writes is unlayered, so against a layered !important it
     loses by construction, before specificity is ever consulted. Confirmed by
     experiment: injecting `html { background: rgb(1,2,3) !important }` at the
     end of <head> left the computed value untouched. `body` is unaffected
     only because that layered sheet happens to name `html` alone.

     So the layer fight is unwinnable from author CSS, and the fix is to stop
     needing it: if body covers the viewport, html's background is never
     visible. 100dvh first for mobile browsers whose toolbars make 100vh
     overshoot; the 100vh line above it is the fallback for engines that do not
     know dvh, and must stay first. Light mode is unaffected — html and body
     resolve to the same --canvas there, so this changes nothing visible. */
  min-height: 100vh;
  min-height: 100dvh;
}

/* Thin, subtle scrollbars instead of the OS default chunky bar — and INVISIBLE
   until something scrolls. The thumb only fades its colour; the track keeps its
   8px, so nothing reflows when the bar appears (hiding it with
   `scrollbar-width: none` would resize every scrollable panel twice per scroll).
   `html.is-scrolling` is set by main.ts on any scroll and dropped when it stops;
   hovering a scrollable area reveals the bar too, for reach-for-the-mouse users. */
/* One universal rule reading a variable, rather than a second rule trying to
   out-specify it: `scrollbar-color` is inherited, so a `html.is-scrolling *`
   override loses to the plain `*` on every element that inherits a value. The
   variable is set once on <html> and inherits everywhere the rule is read. */
* {
  scrollbar-width: thin;
  scrollbar-color: var(--scrollbar-thumb, transparent) transparent;
}
html.is-scrolling {
  --scrollbar-thumb: color-mix(in srgb, var(--soft) 45%, transparent);
}
/* No hover reveal: `*:hover` matches <html> the moment the pointer enters the
   window, so every scrollbar on the page appeared as soon as the mouse was
   anywhere on it and vanished when it left — the opposite of "only while
   scrolling". CSS cannot ask "is this element scrollable", so scrolling is the
   only trigger. */
/* Safari and any Chromium old enough to ignore the standard properties above.
   Where both are honoured the standard ones win and these are dead. */
::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}
::-webkit-scrollbar-thumb {
  background: var(--scrollbar-thumb, transparent);
  border-radius: 8px;
  transition: background 220ms ease;
}
::-webkit-scrollbar-thumb:hover {
  background: color-mix(in srgb, var(--soft) 65%, transparent);
}
::-webkit-scrollbar-track {
  background: transparent;
}

button,
input,
select,
textarea {
  font: inherit;
}

button {
  cursor: pointer;
}

button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
.post-card:focus-visible {
  outline: none;
  box-shadow: var(--focus);
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    scroll-behavior: auto !important;
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}
