/* JSENSE v2 front-end - materials-led showroom aesthetic, inspired by (not copied from) the
   reference the user pointed to. Palette grounded in the business's own materials: warm
   paper/marble, walnut ink, a brushed-brass accent tied to their real metalwork/anodizing
   craft. Approved via an iterated Artifact mockup before this file was written - see that
   conversation for the full design rationale. Scoped entirely to views/v2/* - does not
   touch/override any v1 front-end CSS. */
:root {
  --paper: #F2EDEC; --tone-1: #F5F1ED; --tone-2: #ECE5E0; --tone-3: #E8DFD7;
  /* 2026-09-16: --muted was #8c8171 - measured live (WCAG relative-luminance formula, not
     eyeballed) at 3.82:1 against --surface, under the 4.5:1 AA minimum for normal-size text.
     Darkened to #776d60 (same hue direction, scaled ~0.85x) - 5.07:1, comfortably passes AA
     with a small margin, while staying visibly lighter/distinct from --ink (#2a2621, ~12:1) so
     it still reads as secondary/de-emphasized text, not identical to primary. Sitewide change -
     --muted is used broadly for secondary text (dates, captions, meta info, footer) across v2,
     always on a light surface (--surface/--paper family) here, never a dark backdrop, so this is
     a pure improvement with no context where the lighter value was actually needed. */
  --surface: #ffffff; --ink: #2a2621; --muted: #776d60; --line: #ddd2c6;
  --brass: #a9824f; --brass-deep: #8a6a3d; --deep: #1e1a15;
  /* 2026-09-13: "change the rest of the background color to the gradient of #F2EDEC (lighter)" -
     replaces the old per-section flat tones (--tone-1/2/3, each a bit darker than the last) with
     one shared, lighter gradient derived from --paper itself via color-mix() - so it's still
     admin-configurable (the Theme panel only ever sets --paper; this just derives from whatever
     that is, default or admin-picked) rather than a second hardcoded color to keep in sync. */
  --paper-grad: linear-gradient(180deg, color-mix(in srgb, var(--paper) 100%, #ffffff 55%) 0%, var(--paper) 100%);
}
/* 2026-09-13: the dark-mode block that used to live here (--paper/--line/--muted/etc. all
   redefined dark under prefers-color-scheme:dark) is gone on purpose. views/v2/layouts/
   header.php's admin-theme <style> block already forces --paper/--surface/--ink back to their
   light, admin-configured values regardless of OS theme (a deliberate fix from earlier this
   project - v2 doesn't have a dark mode, it's always the light showroom palette) - but it never
   touched --line/--muted/--brass/--deep, so those quietly kept resolving to this block's dark
   values on any OS-dark-mode browser. Invisible while every var(--line) consumer sat behind an
   opaque var(--surface) tile, but became a literal dark box the moment .tile's own background
   was removed (see .tile below) - .cc-tiles's background: var(--line) rendered as a solid dark
   chocolate panel against the now-light page. Removing this block fixes that and every other
   quiet consumer at once (.cc-map/.modal-box borders, muted caption text) instead of patching
   only the one spot that happened to get reported. */

/* --text-scale (Admin -> V2 -> Theme, default 100%) scales every rem-based font-size on the
   page in one place, since nearly all type here is sized in rem - simpler and more robust than
   threading a scale factor through every individual font-size rule.
   2026-09-15, THE actual root cause of "mobile texts still unreadably small, regardless of
   browser" - found via a real-device diagnostic page with a controlled A/B test: a hardcoded
   `font-size:18px` (no rem/calc/var at all) rendered at a correct, full 18px, while a plain,
   custom-property-free `1.5rem` test element computed to 4.32px - proving html's REAL, USED
   font-size (not just a reporting quirk) was genuinely 2.88px, and that the shrinkage was
   specifically inside this file's own `calc(Npx * var(--text-scale) / 100%)` formula, not a
   WebKit "text autosizer" (an earlier, wrong hypothesis - a hardcoded absolute px value would
   be shrunk by that too, and it wasn't) and not a caching or zoom issue. Root cause: this
   formula stored the multiplier as a PERCENTAGE (100%), and CSS percentages inside a font-size
   calc() resolve against the parent element's computed font-size, not as plain arithmetic - the
   two "100%" tokens (one from `var(--text-scale)`, one from the literal `/100%`) are supposed
   to resolve against the same reference and cancel out to exactly 1 on any spec-compliant
   engine, and normally do (this bug was invisible in every other browser tested, including
   Chrome's own mobile emulation) - but evidently didn't on this real device's WebKit build,
   landing on a spurious ~16% multiplier instead of 100%. Fixed by removing every percentage
   from this calculation: `--text-scale` is now a plain unitless number (1 = 100%), so the
   formula is a straightforward `length * number`, with no percentage-resolution ambiguity for
   any browser to get wrong. `views/v2/layouts/header.php`'s admin theme-override block (which
   sets this from the admin's Theme-tab percentage setting) was updated to divide by 100 there
   instead, emitting a unitless number to match. */
:root { --text-scale: 1; }
* { box-sizing: border-box; }
html { scroll-behavior: smooth; font-size: calc(16px * var(--text-scale)); -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body {
  margin: 0; background: var(--paper-grad); color: var(--ink);
  font-family: 'Work Sans', -apple-system, 'Segoe UI', sans-serif;
  font-size: 1rem; line-height: 1.6; -webkit-font-smoothing: antialiased; overflow-x: hidden;
}
h1, h2, h3 {
  font-family: 'Fraunces', Georgia, serif; font-weight: 600; line-height: 1.1;
  letter-spacing: -0.01em; margin: 0; text-wrap: balance; color: var(--ink);
}
p { margin: 0; }
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
.wrap { max-width: 1160px; margin: 0 auto; padding: 0 40px; }
.wrap-narrow { max-width: 900px; margin: 0 auto; padding: 0 40px; }
.zh { font-family: 'Noto Serif TC', serif; }
.eyebrow { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--brass-deep); display: block; }
.eyebrow .zh { text-transform: none; letter-spacing: 0.05em; margin-left: 4px; }
.muted { color: var(--muted); }

@keyframes kenburns { 0% { transform: scale(1); } 100% { transform: scale(1.1); } }
/* 2026-09-15: "check all gliding/rolling functions for bugs, have it as smooth as possible" -
   audited every transition/animation in this file; all of them already animate only
   transform/opacity/background (none animate a layout-triggering property like width/top/left),
   so there's no classic reflow-jank bug anywhere. The one real gap found: every Collections
   image (.kb) runs this zoom continuously AT THE SAME TIME as its parent .col-track's own
   translateX glide (see createFlow() in v2.js) - two independent, simultaneously-running
   transform animations on nested elements - but only the parent had `will-change: transform`
   telling the browser to pre-promote a compositor layer for it. Without the same hint on .kb,
   a browser may only promote each image to its own layer reactively once its animation is
   already running, which can show as a brief hitch right as a new pair of images scrolls into
   view and starts zooming. Matching the hint on both make it consistent. */
.kb { animation: kenburns 10s ease-in-out infinite alternate; will-change: transform; }
@keyframes flow-y { to { transform: translateY(calc(-1 * var(--flow-dist))); } }
@keyframes flow-x { to { transform: translateX(calc(-1 * var(--flow-dist))); } }

/* ===== Chrome ===== */
/* 2026-09-14: chrome was originally built assuming every page has a full-bleed dark hero photo
   right underneath it (true on Home only) - its white logo/icons had nothing to read against on
   pages without one (Projects listing, coming-soon, and now login/register/profile too), going
   nearly invisible on the light --paper background. A soft top-down scrim guarantees contrast
   everywhere: on Home it reads as a natural top-of-photo darkening (a common treatment, barely
   noticeable against a photo), and on a flat page it's the only thing making the white text/
   icons legible at all. */
/* 2026-09-14: "the unified menu should always stay available while user scroll the page" - was
   position:absolute, which scrolls away with the document like any other in-flow content once
   past the first viewport. Fixed keeps the exact same visual "overlay, reserves no layout space"
   behavior (fixed is out-of-flow, same as absolute was) while staying pinned during scroll -
   every page already opens with a hero/banner directly behind it, so this changes nothing about
   the resting/top-of-page look, only what happens once the visitor scrolls. */
/* 2026-09-14 round 2: "the afloat unified menu background: transparent" - the opaque
   .is-scrolled swap tried above (solid var(--surface) + dark ink once scrolled) is gone per
   direct instruction; the bar keeps this same soft photo-scrim gradient at every scroll
   position now, same as it always did at the top of the page - only the position (fixed vs the
   old absolute) changed, not the background. */
.chrome { position: fixed; top: 0; left: 0; right: 0; z-index: 50; display: flex; align-items: center; justify-content: space-between; padding: 30px 44px; background: linear-gradient(180deg, rgba(20,14,8,0.32) 0%, rgba(20,14,8,0.08) 70%, rgba(20,14,8,0) 100%); }
.chrome .brand { display: flex; align-items: center; gap: 11px; }
.brand-mark {
  /* 2026-09-14 round 2: "too skinny" - the source mark is a tall, narrow letterform ("J"'s
     single vertical stroke, see logo_1773384270.png), so widening the box alone without
     scaling the crop would just reveal blank space beside it, not a bolder glyph. Scaled the
     whole crop up another ~1.35x instead (width/height/background-size/position all together,
     same proportions) - a bigger J reads as less skinny because there's simply more of it,
     not because its own proportions changed. */
  width: 45px; height: 92px; flex-shrink: 0;
  background-image: url('/assets/uploads/settings/logo_1773384270.png'); background-repeat: no-repeat;
  background-size: 148px auto; background-position: -54px -27px;
  filter: brightness(0) invert(1) drop-shadow(0 1px 10px rgba(0,0,0,0.4));
}
.brand-text { font-family: 'Fraunces', serif; font-weight: 600; font-size: 2.4rem; letter-spacing: 0.09em; color: #fff; text-shadow: 0 1px 12px rgba(0,0,0,0.35); line-height: 1; }
.chrome-actions { display: flex; align-items: center; gap: 22px; }
/* "we need the icon a bit larger" - 22px -> 28px for both the account and hamburger icons. */
.icon-plain { background: none; border: none; padding: 0; margin: 0; cursor: pointer; color: #fff; display: grid; place-items: center; filter: drop-shadow(0 1px 6px rgba(0,0,0,0.4)); }
.icon-plain svg { width: 28px; height: 28px; }

/* ===== Hero - rolls (crossfades) between images, hold then advance, clickable dots,
   draggable at any time ===== */
/* 2026-09-14, FINAL DECISION on this rule - explicit instruction: "you have my requirements
   once and for all: a) the image must keep in proportion and ratio and must fill up the entire
   width of the browser." Do not change this again without a new, explicit instruction - full
   history of every round tried before this one is below, kept so nobody re-litigates a
   already-tried-and-rejected option from scratch:
     1. object-fit:cover, fixed height - fills width, keeps proportion (never distorts), crops
        top/bottom/sides on any photo whose ratio doesn't match the container.
     2. a blurred object-fit:cover backdrop behind an uncropped object-fit:contain foreground -
        rejected: visible blur down both sides for any photo whose ratio didn't precisely match.
     3. back to plain cover - rejected: "show the entire image" (i.e. zero cropping).
     4. object-fit:contain, fixed height - rejected: pillarboxes (empty side bars) any photo
        taller/narrower than the container, which reads as "doesn't fill the width."
     5. object-fit:contain with JS dynamically setting the CONTAINER's height to match each
        image's own real aspect ratio (fill width, zero crop, by construction) - technically
        satisfied both asks at once, but broke a THIRD, previously-unstated constraint: overall
        page proportion. Several hero photos are portrait-oriented, so their computed height
        exceeded the viewport height (measured live: 1581px tall on a 768px-tall screen) -
        "looks bad," not proportional to the rest of the page.
   **This final round drops the "zero cropping" requirement** (not present in the "once and for
   all" wording above, unlike every earlier round) in favor of the one that IS explicitly
   required plus looking proportional: back to option 1 (cover, fixed height) - width is always
   100% by construction (a `<img>` at width:100% inside a 100%-wide container fills the width on
   every viewport, dynamically, with no JS needed), aspect ratio is never distorted (cover only
   crops excess, it never stretches), and a fixed, bounded height keeps the section's own
   proportion consistent regardless of which of the 10 images (portrait or landscape) is showing -
   solving the actual "looks bad" complaint that option 5 introduced. */
.hero { position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--deep); cursor: grab; touch-action: pan-y; }
.hero.dragging { cursor: grabbing; }
.hero-track { position: absolute; inset: 0; }
.hero-track .slide {
  position: absolute; inset: 0; opacity: 0; z-index: 1; overflow: hidden;
  transition: opacity 1.4s ease, transform 1.4s cubic-bezier(.22,1,.36,1);
}
.hero-track .slide.active { opacity: 1; z-index: 2; }
.hero-track .slide img.slide-fg { position: relative; z-index: 1; width: 100%; height: 100%; object-fit: cover; -webkit-user-drag: none; user-select: none; }
.hero-track .slide::after { content:''; position:absolute; inset:0; z-index: 2; background: linear-gradient(180deg, rgba(0,0,0,0.28) 0%, rgba(0,0,0,0) 28%, rgba(0,0,0,0) 70%, rgba(0,0,0,0.22) 100%); }
/* Dots: kept visually small (matches the reference's restrained look) but given a much larger
   invisible hit area - the visible 7px circle is drawn by ::after, the button itself is a real
   32px tap target. "The dots are too small to click on it" - was a literal 7x7px click target. */
.hero-dots { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 20; display: flex; gap: 2px; }
.hero-dots button { width: 32px; height: 32px; padding: 0; border: none; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.hero-dots button::after { content: ''; width: 7px; height: 7px; border-radius: 50%; background: rgba(255,255,255,0.45); box-shadow: 0 1px 3px rgba(0,0,0,0.25); transition: background 0.3s ease, transform 0.3s ease; }
.hero-dots button.on::after { background: #fff; transform: scale(1.35); }

/* ===== Optional per-image text overlay - admin-configurable, blank = not rendered at all.
   Sized relative to the frame it sits in (clamp between a floor and a ceiling driven by the
   container's own font-size-as-proxy via vw) so it reads as a deliberate caption, never as
   dominant as the photo itself. */
.v2-overlay {
  position: absolute; inset: 0; z-index: 4; display: flex; flex-direction: column;
  align-items: center; justify-content: center; text-align: center; gap: 10px;
  padding: 6%; pointer-events: none;
}
/* 2026-09-15: "put some thought into it - font style, text size, colors... currently too small,
   font type is too plain including the chinese language." The English line already used the
   site's own display serif (Fraunces); the Chinese line had NO font-family at all, silently
   falling back to the plain UI sans-serif (Work Sans) - the exact "plain" complaint, and also
   sized disproportionately small next to its English partner (0.85-1.15rem vs. 1.3-2.6rem, well
   over 2x smaller at the ceiling). Gave it the same serif treatment this site already uses for
   every OTHER bilingual pairing (Noto Serif TC, matching e.g. .zh-line/.drawer nav a .zh), a
   real weight and size step up so it reads as a considered companion line rather than an
   afterthought caption, and warmed both lines' color from flat white to a brass-tinted ivory
   tied to the site's own accent token instead of a generic literal. */
.v2-overlay-en {
  font-family: 'Fraunces', serif; font-weight: 600;
  color: color-mix(in srgb, #fff 90%, var(--brass) 10%);
  font-size: clamp(1.5rem, 3.8vw, 2.9rem); line-height: 1.18; text-wrap: balance;
  letter-spacing: 0.01em; text-shadow: 0 2px 20px rgba(0,0,0,0.5);
}
.v2-overlay-zh {
  font-family: 'Noto Serif TC', serif; font-weight: 500;
  font-size: clamp(1.05rem, 2.2vw, 1.6rem); line-height: 1.4; letter-spacing: 0.04em;
  color: color-mix(in srgb, #fff 88%, var(--brass) 12%);
  opacity: 0.92; text-shadow: 0 2px 16px rgba(0,0,0,0.5);
}

/* ===== About ===== */
.about { padding: 118px 0 108px; text-align: center; background: var(--paper-grad); }
.about .eyebrow { justify-content: center; display: flex; margin-bottom: 22px; }
.about h2 { font-size: clamp(1.7rem, 3vw, 2.5rem); max-width: 26ch; margin: 0 auto 10px; }
.about h2 + .zh-line { font-size: 1.1rem; color: var(--muted); margin-bottom: 30px; }
.about p.lede { max-width: 58ch; margin: 0 auto; color: var(--muted); font-size: 1.03rem; }
.about p.lede .zh { display: block; margin-top: 14px; font-size: 0.98rem; color: var(--muted); opacity: 0.9; }

/* ===== Inspiration banner + continuous-flow pairs ===== */
/* 2026-09-13: "make it bigger in width... dynamically change the height to fit the screen
   width and height nicely and proportion" - was a flat 38vh regardless of window shape. Full-
   bleed width was already 100vw (no .wrap on this section); aspect-ratio replaces the fixed vh
   so the height is DERIVED from the actual rendered width to hold a fixed, pleasant proportion
   - it recomputes on every resize/orientation change natively (no JS/resize-listener needed),
   which is what "detect the browser screen size and dynamically change" means in CSS terms.
   Wider ratio on desktop (a real banner, not a strip), a taller/near-square one on mobile
   (below) since a 21:8 crop of a full-width photo gets uncomfortably short on a narrow phone. */
.insp-banner { position: relative; width: 100%; aspect-ratio: 21 / 8; height: auto; min-height: 280px; max-height: 62vh; overflow: hidden; }
.insp-banner img { width: 100%; height: 100%; object-fit: cover; }
.insp-banner::after { content:''; position:absolute; inset:0; background: linear-gradient(0deg, rgba(20,16,12,0.42) 0%, rgba(20,16,12,0.02) 45%); }
.insp-banner-label { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 5; color: #fff; text-align: center; width: 90%; }
.insp-banner-label .eyebrow { color: var(--brass); margin-bottom: 8px; }
.insp-banner-label h2 { color: #fff; font-size: clamp(1.8rem, 3.4vw, 2.9rem); }
.insp-gap { height: 64px; background: var(--paper-grad); }

/* ===== Inspiration cards (2026-09-13) - 4 individual cards, each its own admin-configurable
   image list. Idle: slow auto "swipe in and out" loop. Hover: same swipe motion, faster pace -
   see initInspCards() in v2.js. Real horizontal slide transform (not a crossfade), per explicit
   "swipe in and out" wording. ===== */
/* flex, not grid: a grid with repeat(4,1fr) always reserves 4 column-widths even when fewer
   cards have images configured, leaving dead blank space where the missing columns would be
   (confirmed live with 2 of 4 cards filled). flex + justify-content:center groups however many
   cards actually exist and centers them as one unit instead - each card's width is still a
   fixed share of the row (25% desktop / 50% mobile) via flex-basis, so 4 cards still tile
   edge-to-edge exactly as before; fewer cards just center instead of left-packing with a gap. */
.insp-grid { display: flex; flex-wrap: wrap; justify-content: center; gap: 2px; background: var(--paper-grad); }
/* 2026-09-15: real carousel mode (>2 cards configured) - the viewport clips to exactly 2 cards'
   width; the track (.insp-grid itself) drops the wrap/center behavior above and lays every card
   (real + the 2 seam duplicates) out in one continuous row instead, JS-sized in px and slid via
   transform - see initInspCarousel() in v2.js. Untouched (no .is-carousel class rendered) for
   <=2 configured cards, which keeps the original static wrap+center layout exactly as before. */
.insp-viewport.is-carousel { overflow: hidden; }
.insp-viewport.is-carousel .insp-grid { flex-wrap: nowrap; justify-content: flex-start; will-change: transform; }
/* 2026-09-14: "dynamically adjust the image size to meet the device screens, vertical and
   horizontal" - height used to be a flat 78vh/52vh. That's fine in portrait (viewport height
   tracks the phone's own tall shape) but breaks badly in landscape: a phone rotated sideways has
   a SHORT viewport height, so 78vh/52vh collapses toward the min-height floor instead - which is
   a fixed px value tuned for portrait, so on a wide-but-short landscape screen the card ends up
   very wide and short, cropping a portrait-oriented photo down to a sliver. aspect-ratio derives
   height from the card's own (already width-driven, via --insp-card-w) rendered width instead of
   viewport height, so rotating the device just makes the same shape bigger/smaller, never a
   different, worse-cropped one - same technique this file already uses for .insp-banner/
   .proj-banner. max-height is the safety valve for genuinely short landscape screens (caps how
   tall the card gets instead of switching to a different, inconsistent crop shape). */
.insp-card { position: relative; flex: 0 1 calc(var(--insp-card-w, 25%) - 1.5px); aspect-ratio: 2 / 3; min-height: 420px; max-height: 760px; overflow: hidden; background: var(--tone-1, var(--paper)); }
.insp-card-track { position: absolute; inset: 0; }
.insp-card .insp-slide { position: absolute; inset: 0; transform: translateX(100%); transition: transform 0.9s cubic-bezier(.22,1,.36,1); }
.insp-card .insp-slide.active { transform: translateX(0); z-index: 2; }
.insp-card .insp-slide.leaving { transform: translateX(-100%); z-index: 1; }
.insp-card .insp-slide img { width: 100%; height: 100%; object-fit: cover; -webkit-user-drag: none; user-select: none; }
.insp-card-label { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 5; color: #fff; text-align: center; width: 85%; font-family: 'Fraunces', serif; font-weight: 600; font-size: 1.05rem; text-shadow: 0 2px 12px rgba(0,0,0,0.45); pointer-events: none; }
.insp-card-label .zh { display: block; font-family: 'Noto Serif TC', serif; font-size: 0.82rem; font-weight: 500; margin-top: 3px; opacity: 0.9; }
@media (max-width: 900px) {
  .insp-banner { aspect-ratio: 4 / 3; max-height: 48vh; }
  .insp-card { flex-basis: calc(var(--insp-card-w-mobile, 50%) - 1px); aspect-ratio: 3 / 4; min-height: 300px; max-height: 520px; }
}

/* ===== Collections - continuous-flow pairs, left to right ===== */
.collections { padding: 108px 0 0; background: var(--paper-grad); }
.collections .head { text-align: center; margin-bottom: 54px; }
.collections .head .eyebrow { justify-content: center; display: flex; margin-bottom: 18px; }
.collections .head h2 { font-size: clamp(1.7rem, 3vw, 2.4rem); }
/* Same vh -> aspect-ratio reasoning as .insp-card just above - this section is full-bleed
   (100vw), so height derives from actual viewport WIDTH now, which scales consistently across
   both orientations instead of hitting a portrait-tuned min-height floor in landscape. */
/* 2026-09-15 (again): width:auto + aspect-ratio + a max-height that actually clamps means the
   used HEIGHT gets capped at 640px, then - per the CSS box-sizing spec - the used WIDTH gets
   RECOMPUTED from that clamped height via the ratio (640 * 12/5 = 1536px), not left at its own
   fill-available width. On any monitor wide enough to hit the max-height cap (confirmed live at
   1920px: real width measured 1536px, not the page's ~1905px), this silently narrows the whole
   box - a blank gap on the right, unrelated to sizeTrack()'s own (correct) JS math, which was
   just sizing everything to match a box that was already too narrow. `width:100%` makes width
   the definite dimension instead, so only height derives from the ratio and gets clamped -
   width no longer gets recomputed backwards from that clamp. */
.col-viewport { width: 100%; aspect-ratio: 12 / 5; min-height: 380px; max-height: 640px; overflow: hidden; position: relative; background: var(--paper-grad); cursor: grab; touch-action: pan-y; }
.col-viewport.dragging { cursor: grabbing; }
.col-track { display: flex; flex-direction: row; height: 100%; will-change: transform; }
.col-row { display: flex; flex-shrink: 0; width: 100%; height: 100%; }
/* 2026-09-15: was 50% (2 cells/row - see collectionsPairs()'s own comment in v2.js for why that
   caused a 3rd partial image to crowd into every transition). One cell per row now, so it fills
   the row - the row itself is still exactly one viewport-width, unchanged. */
.col-cell { position: relative; width: 100%; height: 100%; overflow: hidden; display: block; }
.col-cell img { width: 100%; height: 100%; object-fit: cover; -webkit-user-drag: none; user-select: none; }
/* 2026-09-13: caption overlays center on the image everywhere now (was bottom-left here) - the
   vignette darkening moved from a bottom-edge gradient to a centered radial one so it still
   sits right behind the (now centered) text regardless of what the photo itself looks like. */
.col-cell.has-caption::after { content:''; position:absolute; inset:0; background: radial-gradient(ellipse at center, rgba(15,12,9,0.5) 0%, rgba(15,12,9,0.18) 55%, rgba(15,12,9,0) 85%); }

/* ===== Contact - card, info tiles, map, modal-triggered form ===== */
.contact { padding: 108px 0 128px; background: var(--paper-grad); }
.contact-head { text-align: center; margin-bottom: 46px; }
.contact-head .eyebrow { justify-content: center; display: flex; margin-bottom: 16px; }
.contact-head h2 { font-size: clamp(1.8rem, 3vw, 2.4rem); }
.contact-head .zh-line { color: var(--muted); font-size: 1.05rem; margin-top: 6px; }
.contact-card { background: var(--paper-grad); box-shadow: 0 28px 64px -34px rgba(30,26,21,0.28); padding: 44px; }
/* 2026-09-15: "make the email column a bit bigger to show one whole line" - a fixed
   repeat(4, minmax(0,1fr)) squeezes every column equally thin as the window narrows, with no
   floor, which is exactly what made the email wrap at a moderate desktop width earlier today
   (fixed then with a <wbr> safety net, kept below). Tried `minmax(220px, 1fr)` first, but that
   floor was too generous for this specific container: measured live, `.cc-tiles` is a stable
   732px wide at (and above) the point where `.wrap-narrow`'s own 900px max-width caps out - a
   220px floor only fits 3 columns there (3 x 243px), pushing the 4th tile (the CTA button) onto
   its own row at a fraction of the full width - exactly the "phone column too large... put
   everything in one row" report. Recalculated properly: 4 columns need
   `4F + 3(gap) <= 732px`, so any floor up to ~182px keeps all 4 in one row at this container's
   real width. 178px (confirmed live: renders as 4 columns at ~182px each, comfortably fitting
   the email on one line) - still drops to 3 or fewer only once the container is genuinely too
   narrow for 4 real columns, rather than every time. */
.cc-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(178px, 1fr)); grid-auto-rows: 1fr; gap: 1px; background: var(--paper-grad); margin-bottom: 32px; align-items: stretch; }
/* 2026-09-15: horizontal padding trimmed 20px -> 15px - at this grid's real 4-column width
   (measured live: 182px/column), the email address needed ~142.4px of content width and was
   getting 142.25px after the old 20px padding - wrapped by a fraction of a pixel. 15px padding
   gives it 152px, comfortably clearing the threshold without giving up the 4-column layout. */
.tile { background: transparent; padding: 24px 15px; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.tile svg { width: 19px; height: 19px; color: var(--brass-deep); flex-shrink: 0; }
.tile .k { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
.tile .v { font-size: 0.92rem; overflow-wrap: break-word; word-break: break-word; }
/* Business Hours naturally runs to 3-4 lines per language (2 languages = up to 8) against the
   other tiles' 1-2 - given its own full-width row and a tighter, smaller-type layout instead
   of forcing it into the same single narrow column as Address/Phone/Email. */
.tile-hours { grid-column: 1 / -1; flex-direction: row; align-items: flex-start; gap: 16px; }
.tile-hours svg { margin-top: 2px; }
.tile-hours .k { flex-shrink: 0; padding-top: 1px; }
.tile-hours .v { font-size: 0.86rem; line-height: 1.55; columns: 2; column-gap: 24px; }
@media (max-width: 560px) {
  .tile-hours { flex-direction: column; }
  .tile-hours .v { columns: 1; }
}
/* CTA tile - sits in the grid cell that's otherwise empty next to Email (3 info tiles across a
   4-column grid leaves column 4 of row 1 blank; being 4th in DOM order, this lands there on its
   own via the grid's default auto-flow, no explicit placement needed). */
.tile-cta { align-items: center; justify-content: center; text-align: center; }
/* "the text is wrapped. Redesign this and add Chinese to it" - was wrapping into an awkward
   3rd line with a single word alone on it ("Send Us" / "a" / "Message") - the tile is narrow
   (a 4-column grid cell) and the button's own generic 30px side padding + a fixed icon gap left
   too little room. Tighter, tile-specific padding/gap, text-wrap:balance so an unavoidable wrap
   splits into two roughly-even lines instead of orphaning one short word, and the EN/ZH pair
   stacks the same way every other bilingual label on this page already does. */
.tile-cta .btn-brass { width: 100%; justify-content: center; padding: 14px 16px; gap: 8px; }
.btn-brass-text { text-wrap: balance; line-height: 1.3; }
.btn-brass-text .zh { display: block; font-family: 'Noto Serif TC', serif; font-weight: 500; font-size: 0.78em; letter-spacing: 0.02em; margin-top: 3px; opacity: 0.92; }
.btn-brass-arrow { flex-shrink: 0; }
.cc-map { position: relative; height: 260px; overflow: hidden; margin-bottom: 32px; background: var(--tone-2); border: 1px solid var(--line); }
.cc-map iframe { width: 100%; height: 100%; border: 0; }
.cc-map .pin { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -100%); text-align: center; }
.cc-map .pin svg { width: 30px; height: 30px; color: var(--brass); filter: drop-shadow(0 4px 6px rgba(0,0,0,0.25)); }
.cc-map .pin-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--brass); margin: -4px auto 0; box-shadow: 0 0 0 6px rgba(169,130,79,0.18); }
.cc-map .pin-label { margin-top: 8px; font-size: 0.76rem; font-weight: 600; color: var(--ink); background: var(--surface); padding: 4px 10px; border: 1px solid var(--line); white-space: nowrap; }
.btn-brass { display: inline-flex; align-items: center; gap: 10px; padding: 13px 30px; background: var(--brass); color: #fff; font-size: 0.85rem; font-weight: 700; letter-spacing: 0.04em; border: none; cursor: pointer; transition: background 0.25s ease; }
.btn-brass:hover { background: var(--brass-deep); }

/* ===== Send-a-message modal ===== */
.modal-overlay { position: fixed; inset: 0; background: rgba(20,16,12,0.5); z-index: 190; opacity: 0; pointer-events: none; transition: opacity 0.3s ease; }
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal-box { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -48%) scale(0.97); z-index: 195; width: min(92vw, 560px); max-height: 86vh; overflow-y: auto; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 40px 90px -30px rgba(20,16,12,0.45); padding: 40px; opacity: 0; pointer-events: none; transition: opacity 0.3s ease, transform 0.3s ease; }
.modal-box.open { opacity: 1; pointer-events: auto; transform: translate(-50%, -50%) scale(1); }
.modal-box h3 { font-size: 1.4rem; margin-bottom: 4px; }
.modal-box h3 .zh { display: block; font-size: 0.9rem; color: var(--muted); font-weight: 500; margin-top: 4px; }
.modal-close { position: absolute; top: 22px; right: 22px; background: none; border: none; color: var(--muted); cursor: pointer; padding: 4px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin: 26px 0 18px; }
.modal-box label { display: block; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.03em; color: var(--ink); margin-bottom: 7px; }
.modal-box label .req { color: var(--brass-deep); }
.modal-box input, .modal-box textarea { width: 100%; border: 1px solid var(--line); background: var(--paper); padding: 11px 13px; font-family: inherit; font-size: 0.92rem; color: var(--ink); }
.modal-box textarea { resize: vertical; min-height: 96px; margin-bottom: 22px; }

/* "the footer is not in the right place" - on a short page (login/register/forgot, just one
   centered card), body{display:flex;flex-direction:column;min-height:100vh} (frontend_luxury.css,
   still loaded for these bridge pages) has no child with flex-grow to absorb the leftover space,
   so the footer just sat wherever the short content ended, with a dead gap below it down to the
   real bottom of the (100vh-tall) page. margin-top:auto pulls it (and only it) down to the true
   bottom instead - a no-op on any page whose real content already exceeds one viewport (Home,
   Projects, etc.), since there's no leftover space left to absorb there anyway. */
.v2-footer { margin-top: auto; padding: 30px 0; text-align: center; font-size: 0.78rem; color: var(--muted); background: var(--paper-grad); }

/* ===== Stub pages (Collections coming-soon, Project not-found) - full-viewport centered,
   same shape as v1's under_construction.php, restyled onto v2's own design language (Fraunces/
   brass/paper instead of v1's gold-on-dark Atelier tokens) and served through v2's own
   header/footer chrome instead of v1's. ===== */
.stub-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--paper-grad); padding: 80px 20px; }
/* When a banner already sits above (see .v2-page-banner), this section doesn't also need to
   fill a whole additional viewport - min-height:100vh here would just stack two full screens. */
.stub-page-with-banner { min-height: 0; padding: 90px 20px; }
.stub-inner { text-align: center; }
.stub-icon { color: var(--brass); margin-bottom: 22px; animation: stub-pulse 2.2s ease-in-out infinite; }
.stub-icon svg { width: 52px; height: 52px; }
@keyframes stub-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.stub-inner h1 { font-size: clamp(1.8rem, 3.2vw, 2.6rem); margin-bottom: 18px; }
.stub-inner h1 .zh { display: block; font-family: 'Noto Serif TC', serif; font-size: 1.1rem; color: var(--muted); font-weight: 500; margin-top: 8px; }
.stub-inner .lede { max-width: 56ch; margin: 0 auto 34px; color: var(--muted); }
.stub-inner .lede .zh { display: block; margin-top: 10px; font-size: 0.95rem; }

/* ===== v2 Projects (list + detail) ===== */
/* 2026-09-14: "should have a nice and large image like the first default [home] page" - an
   admin-configurable banner (Admin -> V2 -> Projects Page), same full-bleed-with-centered-
   overlay-text treatment as the homepage hero/inspiration banner. Falls back to the plain text
   header (unchanged) when no banner has been set, rather than an empty image box. */
/* .v2-page-banner is the same "bounded photo band under the chrome" shape, reused wherever a
   unified-menu page has no other lead image of its own (auth/profile pages, project detail) -
   not a separate ruleset to keep in sync, just an alias onto .proj-banner's. */
.proj-banner, .v2-page-banner { position: relative; width: 100%; aspect-ratio: 21 / 9; min-height: 320px; max-height: 74vh; overflow: hidden; background: var(--deep); }
.proj-banner img, .v2-page-banner img { width: 100%; height: 100%; object-fit: cover; -webkit-user-drag: none; user-select: none; }
.proj-banner::after, .v2-page-banner::after { content: ''; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.1) 40%, rgba(0,0,0,0.4) 100%); }
.proj-banner-label { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2; text-align: center; color: #fff; width: 90%; }
.proj-banner-label .eyebrow { color: var(--brass); margin-bottom: 10px; }
.proj-banner-label h1 { color: #fff; font-size: clamp(2rem, 4vw, 3.2rem); }
.proj-banner-label h1 .zh { display: block; font-family: 'Noto Serif TC', serif; font-size: 1.1rem; font-weight: 500; margin-top: 10px; opacity: 0.92; }
@media (max-width: 700px) { .proj-banner, .v2-page-banner { aspect-ratio: 4 / 5; max-height: 60vh; } }
/* "clicked Sign In, no response" - traced to the full-size banner pushing the actual login
   button below the fold (measured live: 432px banner + card padding put the submit button at
   y=840 on a 768px-tall viewport). Used only for the genuinely short auth-card pages (login/
   register/forgot/reset_password) - see views/v2/layouts/header.php's own note on why
   profile/forum keep the full-size banner. A flat height (not aspect-ratio) since these pages
   have no headline text overlay to protect proportions for for - just enough band to establish
   the "unified menu has a photo behind it" look without competing with the card below it. */
.v2-page-banner-compact { aspect-ratio: auto; height: 220px; min-height: 220px; max-height: 220px; }
@media (max-width: 700px) { .v2-page-banner-compact { aspect-ratio: auto; height: 160px; min-height: 160px; max-height: 160px; } }

.proj-list { padding: 150px 0 110px; background: var(--paper-grad); }
.proj-list-with-banner { padding-top: 90px; }
.proj-list .head { text-align: center; margin-bottom: 54px; }
.proj-list .head h1 { font-size: clamp(1.8rem, 3.2vw, 2.6rem); }
.proj-list .head h1 .zh { display: block; font-family: 'Noto Serif TC', serif; font-size: 1.05rem; color: var(--muted); font-weight: 500; margin-top: 8px; }
/* "Add search and filter to the project list page" */
.proj-filter-bar { margin-bottom: 36px; }
.proj-filter-form { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: center; }
.proj-filter-search { flex: 1 1 260px; max-width: 360px; border: 1px solid var(--line); background: var(--surface); padding: 12px 16px; font-family: inherit; font-size: 0.92rem; color: var(--ink); }
.proj-filter-select { border: 1px solid var(--line); background: var(--surface); padding: 12px 16px; font-family: inherit; font-size: 0.92rem; color: var(--ink); cursor: pointer; }
.proj-filter-submit { padding: 12px 26px; font-size: 0.8rem; }
.proj-filter-clear { font-size: 0.82rem; color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
.proj-filter-clear:hover { color: var(--brass-deep); }
@media (max-width: 560px) { .proj-filter-form { flex-direction: column; align-items: stretch; } .proj-filter-search, .proj-filter-select { max-width: none; } }

/* Collections - style-grouped browse, funnels into Projects' own list/detail/RBAC rather than
   duplicating them (see CollectionsController's docblock). */
.coll-group { margin-bottom: 64px; }
.coll-group-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 22px; border-bottom: 1px solid var(--line); padding-bottom: 14px; }
.coll-group-head h2 { font-size: 1.3rem; }
.coll-group-count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
.coll-group-viewall { flex-shrink: 0; font-size: 0.8rem; color: var(--brass-deep); white-space: nowrap; }
.coll-group-viewall:hover { text-decoration: underline; }
.coll-grid { margin-bottom: 0; }
@media (max-width: 700px) {
  .coll-group-head { flex-direction: column; align-items: flex-start; gap: 6px; }
}

/* Collections product catalog (v1's inventory_products, redesigned for v2) */
.prod-price-input { width: 110px; flex: 0 0 auto; }
.prod-card-price { display: block; margin-top: 8px; font-weight: 600; color: var(--brass-deep); font-size: 0.95rem; font-variant-numeric: tabular-nums; }
.prod-card-price-was { margin-left: 8px; font-weight: 400; color: var(--muted); text-decoration: line-through; font-size: 0.85em; }
@media (max-width: 560px) { .prod-price-input { flex: 1 1 45%; width: auto; } }

.proj-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 28px; }
.proj-card { display: block; }
.proj-card-img { position: relative; aspect-ratio: 4/3; overflow: hidden; background: var(--tone-1, var(--paper)); }
.proj-card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s ease; -webkit-user-drag: none; user-select: none; }
.proj-card:hover .proj-card-img img { transform: scale(1.04); }
.proj-card-body { padding-top: 16px; }
.proj-card-cat { display: block; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--brass-deep); margin-bottom: 6px; }
.proj-card-body h3 { font-size: 1.1rem; }
.proj-card-body h3 .zh { display: block; font-family: 'Noto Serif TC', serif; font-size: 0.85rem; color: var(--muted); font-weight: 500; margin-top: 3px; }
.proj-card-meta { display: block; margin-top: 6px; font-size: 0.78rem; color: var(--muted); }
.proj-pagination { display: flex; justify-content: center; gap: 8px; margin-top: 50px; }
.proj-pagination a { width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--line); font-size: 0.85rem; color: var(--muted); }
.proj-pagination a:hover { border-color: var(--brass); color: var(--brass-deep); }
.proj-pagination a.is-current { background: var(--brass); border-color: var(--brass); color: #fff; }

/* 2026-09-14: "show thumbnails first, click to view the large image" - the old single
   full-bleed hero + smaller grid below is gone; every image (former hero included) is now one
   uniform thumbnail grid, each opening the viewer below. .proj-detail-top adds the clearance
   the removed hero used to provide (the chrome overlays the very top of the page, same as
   .proj-list/.stub-page's own top padding). */
.proj-detail { padding: 64px 0 110px; background: var(--paper-grad); }
.proj-detail-top { padding-top: 150px; }
.proj-detail-head { text-align: center; margin-bottom: 54px; }
.proj-detail-head h1 { font-size: clamp(1.8rem, 3.2vw, 2.6rem); margin: 10px 0; }
.proj-detail-head h1 .zh { display: block; font-family: 'Noto Serif TC', serif; font-size: 1.05rem; color: var(--muted); font-weight: 500; margin-top: 8px; }
.proj-detail-head .lede { color: var(--muted); max-width: 62ch; margin: 0 auto; }
.proj-detail-head .lede .zh { display: block; margin-top: 12px; font-size: 0.95rem; }
.proj-media-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-bottom: 60px; }
.proj-media-cell { position: relative; margin: 0; background: var(--surface); cursor: zoom-in; overflow: hidden; }
.proj-media-cell img { width: 100%; aspect-ratio: 4/3; object-fit: cover; display: block; -webkit-user-drag: none; user-select: none; background: var(--tone-1, var(--paper)); transition: transform 0.4s ease, opacity 0.4s ease; }
/* "make the loading much smoother and fluid" - each thumbnail fades in once it actually has
   bytes, instead of popping in abruptly (or sitting as a bare placeholder tile while a few
   hundred siblings queue ahead of it). */
.proj-media-img { opacity: 0; }
.proj-media-img.is-loaded { opacity: 1; }
.proj-media-cell:hover img { transform: scale(1.04); }
.proj-media-zoom { position: absolute; right: 12px; bottom: 12px; width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; background: rgba(20,14,8,0.55); color: #fff; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease; pointer-events: none; }
.proj-media-zoom svg { width: 17px; height: 17px; }
.proj-media-cell:hover .proj-media-zoom { opacity: 1; }
.proj-detail-back { text-align: center; }
@media (max-width: 900px) {
  .proj-grid, .proj-media-grid { grid-template-columns: repeat(2, 1fr); gap: 16px; }
}

/* ===== Image viewer - zoom, pan, rotate, thumbnail navigator ===== */
.pv-viewer { position: fixed; inset: 0; z-index: 500; background: rgba(10,8,6,0.96); display: none; flex-direction: column; }
.pv-viewer.open { display: flex; }
.pv-top { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; flex-shrink: 0; }
.pv-counter { color: rgba(255,255,255,0.7); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.pv-tools { display: flex; gap: 8px; }
.pv-tools button, .pv-arrow { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.18); color: #fff; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.2s ease, border-color 0.2s ease; }
.pv-tools button:hover, .pv-arrow:hover { background: rgba(255,255,255,0.18); border-color: var(--brass); }
.pv-tools svg, .pv-arrow svg { width: 18px; height: 18px; }
.pv-stage { flex: 1; min-height: 0; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; touch-action: none; cursor: grab; }
.pv-stage.panning { cursor: grabbing; }
.pv-stage img { max-width: 92%; max-height: 92%; object-fit: contain; user-select: none; -webkit-user-drag: none; transition: transform 0.25s ease; will-change: transform; }
.pv-stage.panning img, .pv-stage.no-transition img { transition: none; }
.pv-arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 5; }
.pv-arrow.pv-prev { left: 22px; }
.pv-arrow.pv-next { right: 22px; }
.pv-navigator { display: flex; gap: 8px; padding: 16px 22px; overflow-x: auto; flex-shrink: 0; justify-content: center; }
.pv-navigator img { width: 58px; height: 44px; object-fit: cover; opacity: 0.45; cursor: pointer; flex-shrink: 0; transition: opacity 0.2s ease, outline-color 0.2s ease; outline: 2px solid transparent; outline-offset: -2px; }
.pv-navigator img:hover { opacity: 0.75; }
.pv-navigator img.is-current { opacity: 1; outline-color: var(--brass); }
@media (max-width: 700px) {
  .pv-arrow { width: 34px; height: 34px; }
  .pv-arrow.pv-prev { left: 8px; } .pv-arrow.pv-next { right: 8px; }
  .pv-navigator img { width: 42px; height: 32px; }
}
@media (max-width: 560px) {
  .proj-grid, .proj-media-grid { grid-template-columns: 1fr; }
}

/* ===== Anti-download hardening (best effort - see v2.js) =====
   No client-side measure can fully prevent saving an image a browser has already downloaded to
   render it (devtools Network tab, an OS screenshot, disabling JS) - this is the same honest
   limit this codebase's other image-save protections are documented with elsewhere. What this
   DOES stop: the ordinary right-click "Save Image As", drag-out-to-desktop, text-selection drag,
   and Ctrl/Cmd+S on a page. Applied globally to every v2 image via a plain attribute selector so
   new markup gets it automatically without remembering to add a class. */
body img, .no-download { -webkit-user-select: none; user-select: none; -webkit-user-drag: none; user-drag: none; }

/* ===== Nav drawer ===== */
.overlay { position: fixed; inset: 0; background: rgba(20,16,12,0.4); z-index: 90; opacity: 0; pointer-events: none; transition: opacity 0.35s ease; }
.overlay.open { opacity: 1; pointer-events: auto; }
/* 2026-09-14: "can't read the bottom item" - the drawer had no overflow handling at all, so on
   any viewport shorter than its actual content height (6 nav rows + the login line), the bottom
   simply overflowed past the fixed top:0/bottom:0 box with no way to reach it. overflow-y:auto
   is the real fix (content is always reachable, however short the window); the max-height query
   below additionally shrinks row padding/font-size so most screens never need to scroll at all. */
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(82vw, 380px); background: var(--paper); z-index: 95; transform: translateX(100%); transition: transform 0.4s cubic-bezier(.65,0,.35,1); display: flex; flex-direction: column; padding: 36px 32px; overflow-y: auto; }
.drawer.open { transform: translateX(0); }
.drawer-close { align-self: flex-end; background: none; border: none; color: var(--ink); cursor: pointer; padding: 4px; margin-bottom: 34px; flex-shrink: 0; }
.drawer nav { display: flex; flex-direction: column; }
/* "add icons to all menu items, all items move up a little bit more, have the English and
   Chinese texts a bit larger" - row padding tightened (20px -> 15px, tighter row spacing so the
   list reads more compact overall) while the type itself grows (1.55->1.68rem EN, 0.95->1.02rem
   ZH) - bigger text in a tighter row, not bigger-and-looser. */
.drawer nav a { display: flex; align-items: center; gap: 14px; padding: 15px 0; border-bottom: 1px solid var(--line); font-family: 'Fraunces', serif; font-size: 1.68rem; font-weight: 600; }
.drawer nav a svg { width: 22px; height: 22px; flex-shrink: 0; color: var(--brass-deep); }
.drawer nav a.is-current { color: var(--brass-deep); }
.drawer nav a .zh { display: block; font-family: 'Noto Serif TC', serif; font-size: 1.02rem; font-weight: 500; color: var(--muted); margin-top: 5px; }
/* "add some nice icons beside the texts" - also fixes the EN/ZH text wrapping unpredictably on
   a narrow phone by giving it the same fixed 2-line shape (EN on its own line, ZH block below)
   the main nav rows above already use, instead of one "Sign In · 登入" line that could wrap
   mid-phrase depending on how narrow the drawer gets. */
.drawer .drawer-login { display: flex; align-items: center; gap: 12px; font-size: 1.05rem; font-weight: 600; color: var(--brass-deep); }
.drawer .drawer-login svg { width: 22px; height: 22px; flex-shrink: 0; }
.drawer .drawer-login .zh { display: block; font-family: 'Noto Serif TC', serif; font-size: 0.8rem; font-weight: 500; color: var(--muted); margin-top: 2px; }
/* Logged-out case: .drawer-login is a direct child of .drawer (just "Sign In"), so it alone
   carries the bottom-pinning/divider. Logged-in case: both links live inside .drawer-account
   instead, which carries the same margin-top:auto/divider for the pair of them. */
.drawer > .drawer-login { margin-top: auto; padding-top: 22px; border-top: 1px solid var(--line); flex-shrink: 0; }
.drawer-account { margin-top: auto; padding-top: 22px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 12px; flex-shrink: 0; }
.drawer-logout { color: #b5453c !important; font-weight: 600; }
@media (max-height: 700px) {
  .drawer { padding: 20px 28px; }
  .drawer-close { margin-bottom: 16px; }
  .drawer nav a { padding: 11px 0; font-size: 1.2rem; }
  .drawer nav a .zh { font-size: 0.82rem; margin-top: 2px; }
  .drawer > .drawer-login, .drawer-account { padding-top: 14px; gap: 8px; }
}
@media (max-height: 520px) {
  .drawer nav a { padding: 7px 0; font-size: 1.02rem; }
}

@media (max-width: 900px) {
  .wrap, .wrap-narrow { padding: 0 24px; }
  .chrome { padding: 22px 24px; }
  .about { padding: 84px 0 76px; }
  .col-viewport { aspect-ratio: 1 / 1; min-height: 260px; max-height: 460px; }
  .col-cell .label b { font-size: 1.02rem; }
  .contact-card { padding: 28px; }
  /* 2026-09-14: repeat(N, 1fr) alone leaves each track's minimum width at "auto" (the largest
     min-content size among that column's items) - a long unbroken run like "02-2525-0017" or a
     bilingual address can force a column wider than its fair 1fr share, so the grid (and the
     page) overflows sideways instead of the text wrapping inside its own column. Confirmed live:
     .cc-tiles measured 337px of children inside a 271px container at 375px viewport width,
     matching the reported horizontal scroll/misaligned tiles. minmax(0, 1fr) lets the column
     shrink below that content-driven floor so word-break below actually gets a chance to apply. */
  .cc-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .field-row { grid-template-columns: 1fr; gap: 0; }
  .modal-box { padding: 28px; }
}

/* 2026-09-14: "mobile device fonts are too small and not legible" - the smallest sizes below
   (mostly uppercase kicker/meta labels, sized down deliberately for the desktop editorial look)
   read fine at arm's length on a monitor but get hard to read at phone viewing distance, where
   there's no larger surrounding context to anchor against. Scoped to phones only (901px+ keeps
   the desktop/tablet sizing exactly as designed) and only bumps the sizes that carry real
   information (contact values, project labels, form fields) rather than every last decorative
   eyebrow. Modal text inputs/textarea specifically go to 1rem/16px - below 16px, iOS Safari
   auto-zooms the whole page on focus, which is its own legibility complaint once triggered. */
@media (max-width: 600px) {
  .eyebrow { font-size: 0.78rem; }
  .tile .k { font-size: 0.74rem; }
  .tile .v { font-size: 1rem; }
  .tile-hours .v { font-size: 0.92rem; }
  .btn-brass { font-size: 0.92rem; }
  .proj-card-cat { font-size: 0.76rem; }
  .proj-card-meta { font-size: 0.85rem; }
  .insp-card-label { font-size: 1.15rem; }
  .insp-card-label .zh { font-size: 0.88rem; }
  .v2-overlay-zh { font-size: 0.95rem; }
  .modal-box label { font-size: 0.85rem; }
  .modal-box input, .modal-box textarea { font-size: 1rem; }
}
