/* ==========================================================================
   § THE TYPEFACE — Glegoo, and only Glegoo
   The site previously ran three families: Cinzel (labels, caps), Playfair
   Display (titles) and Shippori Mincho (data), each pulled from Google Fonts.
   All three are replaced by the one family shipped in /assets/fonts, served
   from this origin — so nothing renders differently because a CDN is slow or
   blocked, and there is no third-party request on any page.

   Glegoo has TWO weights, 400 and 700. Where the old system separated meaning
   by CHANGING FACE, that job now falls to size, weight, case and tracking; the
   label/value rules elsewhere in this sheet already carry caps and letter-
   spacing, so they keep reading as labels without a second family.
   font-display: swap keeps text visible while the file loads.
   ========================================================================== */
@font-face {
  font-family: "Glegoo";
  src: url("/assets/fonts/Glegoo-Regular.ttf") format("truetype");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Glegoo";
  src: url("/assets/fonts/Glegoo-Bold.ttf") format("truetype");
  font-weight: 700; font-style: normal; font-display: swap;
}

@charset "UTF-8";
/* ==========================================================================
   The Sheth Family Collection — one stylesheet, every page
   (home, collection, categories, artists, about, 404 + viewers)
   Izanami design language: ink + bone, four serifs at weight 400,
   1px hairlines and 4px dots as the entire chrome vocabulary.
   No letter-spacing. No shadows. No gradients. No radius except circles.
   Order: reset → tokens → base → structural (un-sized) → keyframes →
   PC media block (all desktop sizing) → hover → SP media block → a11y.
   ========================================================================== */

/* ==========================================================================
   § Reset (minimal)
   ========================================================================== */
*,
:before,
:after { box-sizing: border-box; background-repeat: no-repeat; }
* { margin: 0; padding: 0; }
html {
  -webkit-text-size-adjust: 100%;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  touch-action: manipulation;
}
main { display: block; }
h1, h2, h3, h4, h5, h6 { font-weight: 400; font-size: inherit; }
li { list-style: none; }
a { color: currentColor; text-decoration: none; background-color: transparent; }
img { width: 100%; height: auto; max-width: 100%; vertical-align: middle; border-style: none; }
svg:not([fill]) { fill: currentColor; }
button, input, optgroup, select, textarea { font: inherit; color: inherit; }
button {
  -webkit-appearance: none;
  appearance: none;
  display: inline-block;
  position: relative;
  border: none;
  border-radius: 0;
  background-color: transparent;
  text-align: left;
  cursor: pointer;
}
input, select { border-radius: 0; border-style: none; background-color: transparent; }
select { -webkit-appearance: none; -moz-appearance: none; appearance: none; }
select::-ms-expand { display: none; }
[type=search] { -webkit-appearance: textfield; outline-offset: -2px; }
[type=search]::-webkit-search-decoration,
[type=search]::-webkit-search-cancel-button { -webkit-appearance: none; }
[hidden] { display: none !important; }
[aria-controls] { cursor: pointer; }

/* ==========================================================================
   § Tokens (izanami :root, verbatim)
   ========================================================================== */
:root {
  --ink-label: rgba(217, 215, 212, .72);  /* label ink on the dark ground */
  /* ── NIGHT (default / no-JS fallback) ─────────────────────────────────────
     The token NAMES are historical: --color-white is the FOREGROUND (text,
     lines, dots) and --color-black is the BACKGROUND (page + panels). A theme
     is just a second set of these under html[data-theme="day"] (see below), so
     flipping one attribute recolours every page, dialog and the master console. */
  color-scheme: dark;
  --color-white-pure: #fff;      /* hero title only */
  --color-white:      #d9d7d4;   /* Bone — text, lines, dots (FOREGROUND) */
  --color-black:      #0a0801;   /* Ink — page + panel bg (BACKGROUND) */
  --color-black-pure: #000;      /* page-transition veil */
  --color-yellow:     #caa100;   /* Gold — accent */
  /* RGB triplets of fg/bg, so the many raw rgba() hairlines + scrims can flip
     with the theme too: rgba(var(--fg-rgb), .2) etc. (see Phase-2 tokenising). */
  --fg-rgb: 217, 215, 212;       /* == --color-white */
  --bg-rgb: 10, 8, 1;            /* == --color-black */
  --grid-index: 100;
  --loader-index: 90;
  --header-index: 80;
  --transition-index: 70;
  --nav-webgl-index: 60;
  --nav-index: 50;
  --aside-index: 40;
  /* Day-only warm-gallery helpers (Agent A). Every rule that READS these is
     html[data-theme="day"]-scoped, so night never evaluates them — the values
     here are harmless night-neutral fallbacks only. */
  --sfc-mat: transparent;                 /* Day: pure white tile mat */
  --sfc-panel: var(--color-black);        /* Day: taupe raised panel */
  --accent-tan: var(--color-yellow);      /* Day: warm hover accent */
  --hairline-cool: rgba(var(--fg-rgb), .2); /* Day: cool blue-grey hairline */
}

/* ── DAY (light) ────────────────────────────────────────────────────────────
   Warm gallery paper + ink. Same token names, inverted values, so the whole
   site (pages, dialogs, master console, mobile + desktop) recolours from this
   one block. The no-flash <head> script sets data-theme before first paint. */
html[data-theme="day"] {
  color-scheme: light;
  /* solid label ink — ~8.7:1 on white, still clearly secondary to the value */
  --ink-label: #3a3a3a;
  --color-white-pure: #000000;   /* hero title -> full ink on paper */
  /* FOREGROUND. Was #26211a — a warm mid-brown that measured 15.97:1 and so passed
     every contrast check, yet READ dull: on a white page a light-weight serif at
     11-15px needs density, not just ratio. Deepened to near-black with the warmth
     kept (it is still browner than neutral, so the page stays paper and not screen). */
  --color-white:      #000000;
  /* WHITE GALLERY. The photographs carry their own white borders — 860 of them are
     now cropped away, but ~979 are frames, mounts and sculptures that cannot be cut
     without damage. Against a cream page every one of those reads as a white patch.
     Against a WHITE page they are invisible: the margin and the wall are the same
     surface. So Day is white throughout and structure comes from HAIRLINES and
     shadow rather than from a change of tone. The painted backdrop and the cursor
     light are untouched — the room is still lit, it is simply a white room. */
  --color-black:      #FFFFFF;   /* BACKGROUND: page + panels -> gallery white */
  --color-black-pure: #FFFFFF;   /* transition veil -> white */
  --color-yellow:     #9a7800;   /* Gold, deepened for contrast on paper */
  --fg-rgb: 0, 0, 0;             /* == --color-white (day) */
  /* Was 247,242,239 — the cream from BEFORE Day became white. Every veil, scrim and
     guard vignette on the site is computed from this, so each of them was painting a
     faint cream film over a pure-white page: the exact "dull, washed" cast. */
  --bg-rgb: 255, 255, 255;       /* == --color-black (day) == #FFFFFF */
  /* warm-gallery layer roles (Agent A) — mats hide the paintings' baked-in white
     borders; taupe panels raise over the cream page; blush #F5E8E1 is the wall. */
  --sfc-mat: #FFFFFF;            /* pure white tile mat / frame */
  --sfc-panel: #FAFAF9;         /* raised panel: a breath off white, read by its hairline */
  --accent-tan: #F1EAE4;        /* warm hover — the only place tone survives */
  --hairline-cool: #D2DAE3;     /* cool blue-grey hairline */
}

/* ── DAY painted backdrop (Phase 3, replaces the stopgap) ────────────────────
   Day gets its own LIGHT room instead of neutralising the dark one:
   · motion.js §0e now paints --sfc-wall as warm LIGHT paper in Day (WALL_L is
     theme-aware), so .ambientBg / the two header heroes pick up a paper wall
     from the SAME var they already read — no hard colour override here, so the
     live, art-adaptive wall tone still wins. Where §0e has not painted yet the
     var falls back to `transparent` and the paper <body> shows through: light
     either way.
   · webgl.js hides the ambient PAINTING mesh in Day (AmbientMeshItem), so the
     shared canvas stays VISIBLE (the WebGL headline text renders — ink in Day)
     while the dark plate does not. The Phase 1-2 blocker that dropped the whole
     canvas (`.canvas.-common{opacity:0}`) is GONE.
   · NON-WebGL / mobile fallback: hide the dark ambient plate <img> AND the two
     header-hero <img>s (artists "moderns", about hero) so that path is also
     paper wall + ink text in Day — the ink headline would be unreadable over a
     dark featured painting. Night keeps them (bone headline, high contrast).
     Home is untouched: its slideshow is a separate hero with its title BELOW,
     on paper. */
/* SUPERSEDED — these plates are NOT hidden in Day. They carry the artwork that
   bleeds through the white page; see § Day painted backdrop below. */

/* ── DAY home-hero surround — repaint the letterbox walls as warm paper ──────
   The hero slideshow's surround is authored DARK for Night: --slide-wall is
   seeded inline on .homeHero (PHP) and re-set by hero.js every advance, and each
   .homeHero_slide carries its own dark inline background-color (the wall the
   contain-fit painting is letterboxed on). At Day's paper page those dark, often
   blue/green-cast patches read wrong around every slide. In DAY only, force the
   surround to the blush WALL tone (#F5E8E1 == --sfc-wall), so the painting reads
   as hung in the same wall the top safe-zone band paints:
   · --slide-wall !important beats the JS/PHP inline value, so .homeHero's own
     background AND the .homeHero_uplight color-mix (which reads --slide-wall)
     both neutralise to blush/amber — no dark or green cast at the edges.
   · the per-slide inline background-color needs !important to be overridden.
   .homeHero_scrim uses rgba(var(--bg-rgb)) which is already paper in Day, so it
   needs no override. Night: no rule matches — the dark surround is untouched. */
html[data-theme="day"] .homeHero { --slide-wall: #FFFFFF !important; }
html[data-theme="day"] .homeHero_slide { background-color: #FFFFFF !important; }

/* ── DAY warm-gallery layering (Agent A) — WHITE MATS + TAUPE PANELS ──────────
   All of the following is html[data-theme="day"]-scoped, so NIGHT stays byte-
   identical. MATS = pure white (var(--sfc-mat)); the paintings carry baked-in
   WHITE borders that cannot be cropped, so a white mat lets that border dissolve
   into it. PANELS = taupe (var(--sfc-panel)) raised over the cream page. The
   WALL behind the art is blush #F5E8E1 (== --sfc-wall from motion.js). */

/* WHITE MATS — every tile context that shows an image on a mat in Day. The
   collection #grid mat rule (also white) lives in its own G2 block below and
   out-specifies this broad rule; here it covers the .-static category/room
   walls, the deck plates and any other .piece_media, plus the pre-load tint. */
html[data-theme="day"] .piece_media,
html[data-theme="day"] .piece_media.is-loaded { background-color: var(--sfc-mat); }
html[data-theme="day"] .deck_layer { background-color: var(--sfc-mat); }
html[data-theme="day"] .artistCard_img { background-color: var(--sfc-mat); }
/* piece detail dialog — the artwork's backing + the alt-view thumbnails read as
   the blush WALL (#F5E8E1 == --sfc-wall), NOT the white mat. A wide/landscape work
   is contain-fit inside .pieceDialog_figure, so a white backing showed an ugly
   white letterbox band top/bottom (owner: "we don't need this white shade with our
   current background theming"). Blush lets the letterbox match the gallery wall — no
   white band anywhere in the dialog. The collection #grid tile mats stay white
   (their rule is the G2 #grid block below, which out-specifies this one). */
/* ── DAY — WHITE WALL, WARM ROOM ───────────────────────────────────────────
   The photographs carry their own white borders, baked in when they were shot and
   subtly different from one to the next. Painting a white MAT behind them on a
   cream page is what made that visible: a grid of slightly mismatched white
   rectangles, each one announcing its own edge.

   So the PAGE FIELD becomes the very white the photographs already have. The mat
   stops being an object — there is no longer an edge for the eye to catch — and
   the art reads as hanging directly on the wall. Everything that is NOT art keeps
   the warm shades, and that is what gives the room its walls: taupe nav and
   footer, warm dialog, blush hover panel, cool-grey hairlines.

   It is a palette rule, not a per-image fix, so every future Artwork Archive
   upload inherits it automatically with nothing to prepare. */
html[data-theme="day"] body { background-color: var(--sfc-mat); }

/* …and the layer that actually covers it. `.ambientBg` is a fixed, full-viewport
   plate at z-index:-2 painted from --sfc-wall, which motion.js §0e derives FROM THE
   CURRENT ARTWORK (WALL_L_DAY 0.92 — warm paper). It sits above <body>, so recolouring
   the body alone changed nothing you could see: the field stayed warm and the white
   mats kept reading as rectangles cut out of it.

   In Day that wall becomes the same white as the mats, so the field, the mat and the
   photograph's own baked-in border are one continuous surface and the tile edge
   disappears. The art-adaptive warm wall is exactly right at NIGHT, where the work
   glows out of its own colour — it is only wrong behind art that was shot on white.

   Deliberately NOT applied to .aboutHeader_bg / .artistsHeader_bg: those are hero
   chrome rather than the art field, and they should stay warm — that is the room. */

/* ── DAY — ONE UNBROKEN WHITE SURFACE ───────────────────────────────────────
   The remaining "white boxes" are not tiles: the tiles paint nothing at all. They
   are the photographs' OWN white margins — the 979 framed/mounted/3-D works that
   cannot be cropped safely — and they only became visible because the field behind
   them was a hair off white. A margin is invisible on a wall of exactly its own
   colour and unmistakable on a wall that is one step warm, so every surface that
   can sit behind artwork is pinned to the SAME white here, with nothing tinting it.

   Theme-scoped, never viewport-scoped: phone, tablet, landscape and desktop all
   inherit it from the one rule, so the layouts cannot drift apart. */
/* The white lives on <html> ONLY, and body is transparent.
   CSS paints, in order: the root's background -> NEGATIVE z-index layers -> in-flow
   block backgrounds -> content. `.ambientBg` sits at z-index:-2, so an opaque
   background on <body> paints AFTER it and buries the artwork behind the header —
   which is exactly why the painting could never be seen no matter how high its
   opacity went. With the white on <html>, the order becomes:
       white canvas -> the painting -> the page content
   so the field is still unbroken white and the artwork reads on top of it. */
html[data-theme="day"],
html[data-theme="day"] .transition { background-color: #FFFFFF; }
html[data-theme="day"] body { background-color: transparent; }
/* The page WRAPPERS must stay transparent. Painting them white too was belt-and-
   braces that backfired: they are in-flow elements, so their opaque white paints
   OVER the ambient plate at z-index:-2 and buried the artwork behind the header —
   the white field was right, but nothing could ever show through it. The white now
   comes from <body> and .ambientBg alone, both of which sit BEHIND the plate, so
   the field is still unbroken white and the painting can read on top of it. */
html[data-theme="day"] .wrapper,
html[data-theme="day"] .content,
html[data-theme="day"] .main,
html[data-theme="day"] .gallery { background-color: transparent; }

/* The cursor uplight is a WARM GREY GLOW authored for the dark room, where it reads
   as light. On a white wall the same glow can only read as a smudge — it is the
   warm cast that was making the margins show. Day keeps the cursor's fluid warp
   (that displaces, it does not tint); only the tinting glow goes. */
html[data-theme="day"] .sfcUplight { display: none; }

/* A tile must never paint a rectangle of its own in Day, loaded or not: with the
   artwork sitting directly on the wall there is nothing for an edge to form against. */
html[data-theme="day"] .piece_media,
html[data-theme="day"] .piece_media.is-loaded,
html[data-theme="day"] .gallery_grid:not(.-static) .piece .piece_media,
html[data-theme="day"] .gallery_grid:not(.-static) .piece .piece_media.is-loaded {
  background-color: transparent;
  border-color: transparent;   /* the mat hairline was the faint line around each work */
}

/* The dialog had the same fault one level down: the artwork sat on blush, so a
   white-bordered photograph drew a white block inside the dialog. Its art surfaces
   take the wall white too — warm chrome around them, white under the work. */
html[data-theme="day"] .pieceDialog_figure,
html[data-theme="day"] .pieceDialog_thumbImg { background-color: var(--sfc-mat); }

/* The hover record now has a WHITE wall to sit on rather than a cream one, so it
   is pushed warm to stay legible as a card laid over the work — blush, not the
   page tone it would otherwise inherit through --bg-rgb. */
/* The hover record in Day. It was blush at 95% — a warm panel dropped onto a white
   wall, reading as a different material from everything around it and nearly opaque,
   so the work underneath vanished the moment you hovered. It is now a light GREY
   veil at 82%: cool enough to belong to the white room, sheer enough that the
   painting still reads through it while the text stays legible on top. The text is
   pinned to the page ink rather than inherited, because the panel inherits a
   FOREGROUND token that is bone in Night — on a pale veil that was near-invisible. */
html[data-theme="day"] .piece_mask {
  background-color: rgba(246, 246, 245, .82);
  -webkit-backdrop-filter: blur(2px);
  backdrop-filter: blur(2px);   /* keeps type crisp over a busy painting without more opacity */
  color: #26211A;
}
html[data-theme="day"] .piece_maskTitle { color: var(--color-white); }
html[data-theme="day"] .piece_maskMeta,
html[data-theme="day"] .piece_maskLine  { color: var(--color-white); }
html[data-theme="day"] .piece_maskLabel { color: var(--ink-label); }
/* fullscreen zoom/pan viewer — a clean white mat so the border vanishes */
html[data-theme="day"] .zoomViewer { background-color: var(--sfc-mat); }

/* TAUPE PANELS — raised surfaces over the cream page (text stays ink). */
/* TRANSPARENT, not panel-coloured. These rows are the entire page below home's
   hero, and an opaque #FAFAF9 panel across all of it is what made the painted
   backdrop impossible to see there — the plate sits at z-index -2 and every row
   painted straight over it. The panel never carried meaning through its FILL
   anyway: it is read by its hairline rules and its hover, both kept below. */
html[data-theme="day"] .browsePanel {
  background-color: transparent;
  border-block-start-color: var(--hairline-cool);
}
html[data-theme="day"] .galleryBrowse .browsePanel:last-child { border-block-end-color: var(--hairline-cool); }
/* warm hover accent (the JS ink-wash hover is neutralised by the taupe above) */
html[data-theme="day"] .browsePanel:hover {
  /* translucent so the work still reads underneath the hovered row */
  background-color: rgba(241, 234, 228, .72);
}
/* collection filter dialog — a taupe frosted sheet (keeps the backdrop blur) */
html[data-theme="day"] .filtersDialog { background-color: rgba(229, 214, 205, .92); }
/* master console / tools dock (admin only) */
html[data-theme="day"] .mtools_panel { background-color: var(--sfc-panel); }
/* fullscreen nav overlay field */
html[data-theme="day"] .nav { background-color: var(--sfc-panel); }
/* footer band */
html[data-theme="day"] .footer {
  background-color: var(--sfc-panel);
  border-block-start-color: var(--hairline-cool);
}

/* ==========================================================================
   § Base
   ========================================================================== */
body {
  background-color: var(--color-black);
  color: var(--color-white);
  overflow-wrap: anywhere;
  word-break: normal;
  line-break: strict;
  font-family: "Glegoo", Georgia, serif;
}
::-moz-selection { color: var(--color-black); background-color: var(--color-white); }
::selection { color: var(--color-black); background-color: var(--color-white); }
:focus:not(:focus-visible) { outline: 0; }
:focus-visible { outline: 1px solid var(--color-white); }

/* ==========================================================================
   § Utilities
   ========================================================================== */
.layer { position: relative; z-index: 1; }
.line { display: block; }
.clip { overflow: hidden; overflow: clip; } /* line-mask reveal wrapper (motion.js) */
.uppercase { text-transform: uppercase; }
.visuallyHidden {
  display: block flow !important;
  visibility: visible !important;
  opacity: 0 !important;
  position: fixed !important;
  inset: 0 !important;
  inline-size: 4px !important;
  block-size: 4px !important;
  margin: 0 !important;
  padding: 0 !important;
  border: none !important;
  contain: strict !important;
  pointer-events: none !important;
}

/* ==========================================================================
   § Shell
   ========================================================================== */
.wrapper { position: relative; min-height: 100vh; overflow: hidden; overflow: clip; }
.main { width: 100vw; }

/* ==========================================================================
   § Loader
   ========================================================================== */
.loader {
  position: fixed;
  inset: 0;
  z-index: var(--loader-index);
  width: 100vw;
  height: 100vh;
  height: 100svh;
  background-color: var(--color-black);
}
.loader_inner { height: 100%; }
.loader_contents { display: grid; align-content: end; width: 100%; height: 100%; }
.loader_progress { position: absolute; right: 0; }
/* chars stay visible by default — motion.js splits + hides them itself */
.loader .char { display: inline-block; }

/* ==========================================================================
   § Header
   ========================================================================== */
.header {
  position: fixed;
  top: 0;
  left: 0;
  z-index: var(--header-index);
  width: 100vw;
  pointer-events: none;
}
.header a,
.header button { pointer-events: auto; }
.header_block { display: grid; align-items: center; }
.header_block > *:last-child { justify-self: end; }
/* Day/Night toggle shares the right-aligned last grid cell with MENU. Its label
   uses the same small-caps text treatment as "menu". */
/* Header controls — the Day/Night toggle sits to the LEFT of MENU on ONE row.
   DOM order is [MENU, toggle]; row-reverse paints the toggle first (left) and MENU
   second (right), so tab order stays MENU→toggle while the eye reads toggle→MENU.
   Both are [glyph ∕ label] stacks centred on the row, so their labels line up.
   Same on every page + theme, mobile and desktop. */
/* gap was 1.6em — reduced 60% to .64em, on EVERY viewport (desktop, portrait and
   landscape alike). The pair reads as one control cluster at this distance; the
   old 1.6em let MENU and the toggle drift apart into two unrelated items. */
/* Day/Night sits ABOVE the MENU trigger. The DOM order is MENU then toggle (the
   trigger must come first for keyboard order), so `column-reverse` stacks the
   toggle on top without reordering the markup or the tab sequence. Declared once,
   unscoped by viewport, so desktop, phone portrait and phone landscape cannot
   disagree — the earlier row layout was being re-specified per breakpoint, which
   is exactly how they drifted apart before. */
.header_controls {
  display: flex;
  flex-direction: column-reverse;   /* DOM is MENU→toggle; reversing puts Day/Night on top
                                       without disturbing markup or tab order */
  /* Both children are inline-grids that CENTRE their own icon over their own label.
     Aligning the boxes to flex-end therefore lined up their right EDGES while their
     contents — which is all you actually see — stayed off-centre from each other,
     because the two buttons are different widths. Centring them puts both icons and
     both labels on one vertical axis, which is what reads as aligned. */
  align-items: center;
  gap: .5em;
  text-align: center;
}
/* One type size and one leading for both labels, so "Day" and "MENU" are visibly the
   same voice. They already share the Cinzel classes, but the two labels sat in
   differently-nested spans and inherited different line-heights. */
.themeToggle_label,
.openTrigger_text { font-size: inherit; line-height: 1; letter-spacing: .1em; }
/* Day/Night toggle — sits directly under MENU as a matching [glyph ∕ label] unit
   (mirrors MENU's [dot ∕ label]). The label is the SAME Cinzel face/size/colour and
   FULL weight as "menu" — no dimming (opacity 1) so it reads identically. The glyph
   (sun/moon) swaps purely on the live data-theme — moon in Night, sun in Day — correct
   at first paint with no JS. currentColor = --color-white (ink in Day, bone in Night). */
/* The toggle STRUCTURALLY MIRRORS the MENU trigger so their labels line up at any
   width: same inline-grid + [glyph-head ∕ label] rows, and — via the shared media
   rules further down — the SAME grid gap + padding as .openTrigger, plus the label
   carries MENU's body padding. The glyph-head is the SAME height as MENU's dot head;
   the sun/moon is larger but OVERFLOWS that head (centred on it), so it reads like
   MENU's dot-over-label but with a real icon, while the label sits exactly where
   MENU's label sits. Full opacity + the same Cinzel face as "menu" (set on markup). */
.themeToggle { display: inline-grid; justify-items: center; color: var(--color-white); opacity: 1; }
.themeToggle_icon { display: grid; place-items: center; height: clamp(3px, .25vw, 4.5px); overflow: visible; line-height: 0; }
.themeToggle_glyph { grid-area: 1/1; width: 1.16em; height: 1.16em; display: block; }
.themeToggle_glyph.-sun { display: none; }
html[data-theme="day"] .themeToggle_glyph.-moon { display: none; }
html[data-theme="day"] .themeToggle_glyph.-sun { display: block; }
.themeToggle_label { display: inline-block; }
/* Day home hero — NO separate header band. Earlier a solid blush bar was painted
   behind the fixed chrome (html[data-theme="day"][data-page="home"] .header::before,
   height clamp(92px,7vw,172px), background #F5E8E1, z-index:-1). It covered the top
   strip of the contain-fit art and left a visible seam/rectangle where the bar ended
   — the owner: "not full bleed till the top". REMOVED so the hero itself is the one
   seamless full-bleed field: .homeHeader is 100svh and .homeHero is inset:0 with its
   Day surround forced to blush #F5E8E1 (--slide-wall / .homeHero_slide above), so the
   blush reaches y=0 behind the fixed header on the home page — no seam, no top band.
   The brand, Day/Night toggle and MENU are ink (--color-white = #26211a in Day) and
   read on that blush field; the art stays fully uncropped (object-fit:contain). Night:
   this was always day+home-scoped, so night never had a band — untouched. */
.header_logo { display: inline-grid; }
/* Primary logo mark — replaces the wordmark. Height-locked; width derives from the
   file's own 2285×601 aspect ratio. Swapped by theme so it always contrasts:
   Primary Black on the Day/light ground, Primary White on the Night/dark ground. */
.header_logoMark {
  display: block;
  height: clamp(34px, 5vw, 60px);
  aspect-ratio: 2285 / 601;
  background: no-repeat left center / contain;
}
html[data-theme="day"]   .header_logoMark { background-image: url("/gallery/logo/primary-black.png"); }
html[data-theme="night"] .header_logoMark { background-image: url("/gallery/logo/primary-white.png"); }
/* Brand hides on scroll-down so it stops overlapping page text; reappears at the
   top or on scroll-up. Toggled by the inline handler in chrome-bottom.php via
   `.header.-min`. Only the wordmark + works-count fade — the menu trigger stays
   put so nav is always reachable. */
.header_logo,
.header_count { transition: opacity .45s cubic-bezier(.25, 1, .5, 1), transform .45s cubic-bezier(.25, 1, .5, 1); }
/* `-scrolled` is set the INSTANT the page leaves the top (see chrome-bottom.php)
   and cleared only on return, which is what "hidden as soon as scrolling starts"
   asks for. It is deliberately a SEPARATE state from `-min`: `-min` is directional
   (hide on scroll-down, return on scroll-up) and that behaviour is tuned for the
   wordmark, which should come back the moment you head upward. The controls want
   the blunter rule, so they get their own flag rather than both sharing one and
   fighting over the trigger. Applies on desktop as well as phones. */
.header_controls { transition: opacity .45s cubic-bezier(.25, 1, .5, 1), transform .45s cubic-bezier(.25, 1, .5, 1); }
.header.-scrolled .header_controls { opacity: 0; transform: translateY(-.55rem); pointer-events: none; }

/* The logo and the works-count leave on ANY scroll away from the top, and come back
   only at the top — the same `-scrolled` rule the MENU and Day/Night controls already
   follow, so the whole header now behaves as one thing.

   `-min` alone was directional: they hid on the way down and RETURNED the moment you
   nudged upward, which put a fixed black wordmark straight over the work you were
   reading, in both themes and on every viewport. Nothing else in this header returns
   mid-page, so neither should these. `-min` is kept below because it still fires
   first on the way down — the fade simply cannot be undone until you are back at the
   top. */
.header.-min .header_logo,
.header.-min .header_count,
.header.-scrolled .header_logo,
.header.-scrolled .header_count { opacity: 0; transform: translateY(-.55rem); pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
  .header_logo, .header_count, .header_controls { transition: opacity .2s linear; transform: none; }
  .header.-min .header_logo, .header.-min .header_count,
  .header.-scrolled .header_logo, .header.-scrolled .header_count,
  .header.-scrolled .header_controls { transform: none; }
}

/* -- menu trigger: dot roller + menu/close label stack + hairline -- */
.openTrigger { display: inline-grid; justify-items: center; }
.openTrigger_head,
.openTrigger_circle { display: inline-grid; }
.openTrigger_circleInner {
  grid-area: 1/1;
  aspect-ratio: 1/1;
  border-radius: 50%;
  background-color: currentColor;
}
.openTrigger_body { display: inline-grid; }
.openTrigger_text { grid-area: 1/1; }
.openTrigger_textInner { display: grid; }
.openTrigger_textInner span { grid-area: 1/1; }
.openTrigger [data-header=close] { opacity: 0; }
.openTrigger[aria-expanded=true] [data-header=menu] { opacity: 0; }
.openTrigger[aria-expanded=true] [data-header=close] { opacity: 1; }
.openTrigger_line {
  grid-area: 1/1;
  align-self: end;
  width: 100%;
  height: 1px;
  background-color: currentColor;
}

/* ==========================================================================
   § Nav overlay
   ========================================================================== */
.nav {
  display: grid;
  position: fixed;
  top: 0;
  left: 0;
  z-index: var(--nav-index);
  width: 100vw;
  height: 100%;
  background-color: var(--color-black);
  visibility: hidden;
  opacity: 0; /* motion.js animates via autoAlpha */
}
.nav_contents { grid-area: 1/1; height: 100%; overflow-y: auto; }
.nav_inner { height: 100%; }
.nav_block { display: grid; grid-template-rows: 1fr auto; height: 100%; }
.nav_list { align-self: center; }
.nav_link { display: inline-grid; position: relative; }
.nav_linkLine {
  width: 100%;
  height: 1px;
  background-color: var(--color-white);
  transform: scaleX(0);
  transform-origin: left center;
  will-change: transform;
}
.nav_linkCircle {
  opacity: 0;
  position: absolute;
  top: 50%;
  translate: 0 -50%;
  border-radius: 50%;
  background-color: currentColor;
}
.nav_link[aria-current] .nav_linkCircle { opacity: 1; }
.nav_subList { display: grid; }
.nav_subLink { display: inline-grid; width: fit-content; }
.nav_foot { border-block-start: 1px solid rgba(var(--fg-rgb), .2); }
/* S4 — nav WebGL canvas (izanami canvas.-nav): overlays the nav contents in
   the same grid cell, above them (--nav-webgl-index) but click-through. Its
   render loop runs only while the nav is open (webgl.js SFC_NAV_WEBGL,
   started/stopped by motion.js §5). Main labels go transparent per-mesh from
   JS only once their texture is live — never from CSS — so no-GL/failure
   paths always keep readable DOM glyphs. */
.nav_canvas {
  grid-area: 1/1;
  position: relative;
  z-index: var(--nav-webgl-index);
  pointer-events: none;
}
.canvas.-nav { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

/* ==========================================================================
   § Aside HUD
   ========================================================================== */
.aside {
  display: grid;
  align-items: end;
  position: fixed;
  bottom: 0;
  left: 0;
  z-index: var(--aside-index);
  width: 100vw;
  height: 100%;
  pointer-events: none;
}
.aside p { pointer-events: auto; }
.aside_block { display: grid; align-items: end; }
.aside_scroll { justify-self: end; }
/* live match count: a VERTICAL label pinned bottom-left (mirrors the Catalogue
   label). It lives inside [data-aside] so it fades with the HUD on scroll; it is
   an OVERLAY (position:absolute) with pointer-events:none, confined to the left
   gutter — so it never shifts, touches, or blocks the grid. Same on mobile+desktop.
   (`.aside .aside_matchCount` outspecifies `.aside p` so pointer-events:none wins.) */
.aside .aside_matchCount {
  position: absolute;
  left: 2.2vw;
  bottom: 2.5vw;
  margin: 0;
  writing-mode: vertical-rl;
  inline-size: max-content;
  height: fit-content;
  color: var(--color-white);   /* was a faint muted gold at .5 opacity — too dim */
  font-weight: 700;            /* bold, so it reads clearly */
  pointer-events: none;
}

/* Back-to-top prompt — fixed bottom-right, revealed by motion.js §7 after a deep
   scroll (via gsap autoAlpha, so it starts visibility:hidden here). A small white
   prompt so a long collection never feels like endless scrolling. */
.pageTop {
  position: fixed;
  right: 2.5vw;
  bottom: 2.5vw;
  z-index: var(--aside-index);
  color: var(--color-white);
  font-weight: 700;
  text-decoration: none;
  opacity: 0;
  visibility: hidden;
}
.pageTop::before { content: "\2191\00a0"; }   /* ↑ + nbsp */
/* dual live clocks — [hidden] by default, JS reveals per page + blinks colons */
.aside_times { font-variant-numeric: tabular-nums; }
.aside_timesColon { display: inline-block; }

/* ==========================================================================
   § Footer
   ========================================================================== */
.footer { border-block-start: 1px solid rgba(var(--fg-rgb), .2); }
.footer_head { display: grid; }
.footer_nextLink { display: inline-grid; position: relative; width: fit-content; }
/* legacy underline — only while the leader line/roller structure is absent */
.footer_nextLink:not(:has(.footer_nextLinkLine)):after {
  content: "";
  display: block;
  width: 100%;
  height: 1px;
  background-color: var(--color-white);
  will-change: transform;
}
/* big Playfair leader — izanami footer_nextLinkBlock proportions (line | text)
   recreated on the link itself: B1 emits text span + line span inside the <a> */
.footer_nextLink:has(.footer_nextLinkLine) { align-items: center; }
.footer_nextLinkText { grid-row: 1; grid-column: 2; }
.footer_nextLinkLine {
  grid-row: 1;
  grid-column: 1;
  width: 100%;
  height: 1px;
  background-color: var(--color-white);
  transform-origin: left center;
  will-change: transform;
}
.footer_foot { display: grid; align-items: end; }
.footer_pageTop { justify-self: end; }

/* ==========================================================================
   § Page transition veil
   ========================================================================== */
.transition {
  opacity: 0;
  position: fixed;
  inset: 0;
  z-index: var(--transition-index);
  width: 100vw;
  height: 100vh;
  height: 100lvh;
  background-color: var(--color-black-pure);
  pointer-events: none;
}

/* ==========================================================================
   § Mesh (WebGL mounts) + mist
   ========================================================================== */
.mesh { position: absolute; inset: 0; }
.mesh canvas { width: 100%; height: 100%; }
/* D3 — the no-GL/mobile fallback for the SHOWCASE meshes (hero backgrounds,
   img meshes, triptych) is CONTAIN: the whole artwork shows inside the
   full-bleed box, letterboxed, matching the WebGL contain path. Its container
   already carries the painted ambient/ink behind, so the margins read as
   intentional (never a bone box). G3 — the ambient backdrop's own fallback is
   contain TOO now (it used to be the last cover-crop): the override just below
   keeps it contain and paints the surround with the wall tone. */
.mesh_fallback { width: 100%; height: 100%; object-fit: contain; }

/* The orientation swap wraps every plate in <picture>, which is an INLINE box and
   would become the img's sizing context — the plate would collapse instead of
   filling its layer. `display: contents` removes the wrapper's own box, so the
   <img> lays out exactly as the direct child it used to be and every existing
   `.ambientBg .mesh_fallback` rule keeps applying untouched. */
/* The no-WebGL plate matches the shader in BOTH themes: a contained plate leaves
   the page showing either side of the work, which on the dark theme reads as black
   pillars around a lit rectangle. */
.ambientBg .mesh_fallback { object-fit: cover; }

.ambientBg picture,
.artistsHeader_bg picture,
.aboutHeader_bg picture { display: contents; }

/* Ambient painted backdrop (S2) — [data-ambient-bg], every page, fixed
   behind ALL content and BELOW the webgl canvas (canvas is z-index -1, this
   is -2), so hero backdrops take over the fold and the ambient takes over as
   they scroll past. Under GL the plate renders as a full-viewport mesh in
   the shared canvas (opacity .22 + a .55 ink scrim baked into its shader);
   this CSS path is the no-GL/mobile fallback: img .18 under a flat .55
   scrim — never bare black, always legible.
   D1 raised the plate ALPHA only (GL .16 → .22, CSS .12 → .18; both scrims
   left at S2's .55) because at .16 the painting read as noise, not as a
   room. Measured over the real ambient plates (9,216 px each): the brightest
   texel of the darkest page composites to rgb(34,32,26) and bone #d9d7d4 on
   it is 11.29:1; at the median it is 13.5:1 — AAA throughout.
   E1 — the gallery (home) landing no longer carries an ambient PAINTING at
   all: below its single top-bleed hero it is a bare ink field lit only by the
   mouse-fluid dye-glow (webgl.js POST_FRAGMENT). So [data-ambient-bg] now
   ships on the collection / artists / about / 404 pages only (chrome-top.php
   skips home); those keep D1's proven .22/.55 (GL) · .18/.55 (fallback). */
.ambientBg {
  position: fixed;
  inset: 0;
  z-index: -2;
  overflow: hidden;
  overflow: clip;
  pointer-events: none;
}
/* --------------------------------------------------------------------------
   G3 — PLATE AND WALL. D3 left the ambient backdrop cover-CROPPED ("atmosphere,
   not a shown work"); the owner's complaint is that this is exactly what breaks
   the room illusion. The backdrop is now a PLATE hung on a WALL:
   · the whole artwork is CONTAINED — nothing is cut off, ever;
   · the surround is not a letterbox bar, it is the WALL TONE the backdrop
     contract supplies (--sfc-wall) — an OKLCH tone built from THIS work's own
     edge pixels, chroma-capped, pulled a quarter of the way to the house hue
     (37°) and pinned to one perceptual lightness (OKLab L .225) so every wall
     is equally text-safe. The plate therefore never ends on a seam: its edge is
     a low-contrast step into a tone taken from its own border (and the GL path
     feathers that step to nothing — webgl.js AMBIENT_FRAGMENT).
   · plate exposure is per-image (--sfc-plate-a = the contract's `alpha`), so
     the 5.6x brightness spread across the collection collapses to ~1.1x.
   The GL and no-GL paths now AGREE (they used to disagree: contain vs cover,
   .22 vs .18). The ink scrim that used to be a flat full-viewport :after
   applies to the PLATE ONLY here — filter:brightness(1-s) is mix(colour, ink,
   s) — exactly as the shader's u_scrim does, because the wall needs no scrim:
   at OKLab L .225 its relative luminance is ~.011 and bone #d9d7d4 on it is
   ~11.9:1 (AAA).

   G4 — TRANSPARENCY RETUNE (owner, from the live site: "the background
   paintings are hardly visible, the mask is too strong"). Two numbers moved,
   in both paths at once: s .55 -> .35 (here and webgl.js AMBIENT_SCRIM) and
   the contract's exposure x PLATE_BOOST 2.0 (motion.js §0e, which feeds BOTH
   --sfc-plate-a and u_opacity). Net: ~2.9x more of the painting's own
   structure survives to the screen. It is safe because the flat wash is no
   longer the only defence — the local contrast guard below took over the
   headline. Measured over all 6,897 profiled works, bone body text over the
   plate's brightest 3x3 cell: worst 7.03:1, median 9.62:1.
-------------------------------------------------------------------------- */
.ambientBg { background-color: var(--sfc-wall, transparent); }
/* home's wall-only ambient exists ONLY to give webgl.js a mesh (so the fluid
   runs); it must paint nothing — the dark body ink is the field, lit by the
   fluid dye-glow. */
.ambientBg.-wallOnly { background-color: transparent; }
.ambientBg .mesh_fallback {
  opacity: var(--sfc-plate-a, .22);
  object-fit: contain;
  /* G4 — was brightness(.45). Tracks webgl.js AMBIENT_SCRIM: brightness(1-s),
     i.e. == the shader's mix(colour, ink, .35). (mix() also adds ink x .35 —
     ~3/255 — which brightness() drops; below the quantisation of the composite,
     and dropping it errs DARK, never bright.) These two numbers must move
     together or the GL and no-GL paths disagree again. */
  filter: brightness(.65);
}
/* G3 — CONTRAST GUARD (no-GL path). The :after is no longer a flat scrim: it is
   a soft LOCAL vignette that motion.js §0e parks over the headline and fades in
   ONLY when the plate's stored 3x3 luminance grid says that region is too
   bright. Deepening the scrim there beats dimming the whole painting. The GL
   path does the same job inside the shader (u_guard / u_guardAmt). At rest
   --sfc-guard-a is 0 and this layer costs nothing. */
.ambientBg:after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: radial-gradient(
    ellipse var(--sfc-guard-rx, 55%) var(--sfc-guard-ry, 30%)
    at var(--sfc-guard-x, 50%) var(--sfc-guard-y, 18%),
    rgba(var(--bg-rgb), .8) 0%, rgba(var(--bg-rgb), .42) 45%, rgba(var(--bg-rgb), 0) 100%);
  /* The no-GL twin of the shader's guard, removed for the same reason: a dark pool
     behind the page title read as a patch, not as light. Pinned to 0 rather than
     deleted, so motion.js can keep writing --sfc-guard-a harmlessly. */
  opacity: 0;
}

.homeHeader_mist,
.notFound_mist {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 45%;
  overflow: hidden;
  overflow: clip;
  pointer-events: none;
}
.mist_item {
  position: absolute;
  bottom: 0;
  left: 0;
  height: 100%;
  opacity: .14;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
  animation-play-state: paused;
}
/* G3 — the drifting mist strips were the last cover-CROP on the home/404 heroes.
   The band is transparent, so containing the work leaves no bars — just the
   whole painting drifting at .14 instead of a cropped smear of its middle. */
.mist_item img { width: 100%; height: 100%; object-fit: contain; }
.mist_item:nth-of-type(1) { animation-name: mistMove01; animation-duration: 100s; animation-delay: -50s; }
.mist_item:nth-of-type(2) { animation-name: mistMove02; animation-duration: 100s; }
.homeHeader_mist.-inView .mist_item,
.notFound_mist.-inView .mist_item,
.mist_item.-inView { animation-play-state: running; }
/* BUG1 — the drifting mist is the sole cause of the HOME hero's empty side
   band, the visible seam, and the "top static / bottom moving" mismatch: a
   wide work `contain`ed in a short 45% band leaves bare bands at its sides and
   drifts on a ~100s cycle under an otherwise static hero mesh. Remove it on the
   home page only (data-page="home" is stamped on <html>). The 404 mist keeps
   working — it uses .notFound_mist / .-notFound, which this rule never touches. */
html[data-page="home"] .homeHeader_mist { display: none; }
/* 404 — same mist, reversed drift under the .-notFound container.
   izanami notFoundCloud rows: 50s/100s/25s with −25s/−50s/−12.5s offsets */
.-notFound .mist_item:nth-of-type(1) { animation-name: mistMove01R; animation-duration: 50s; animation-delay: -25s; }
.-notFound .mist_item:nth-of-type(2) { animation-name: mistMove02R; animation-duration: 100s; animation-delay: -50s; }
.-notFound .mist_item:nth-of-type(3) { animation-name: mistMove01R; animation-duration: 25s; animation-delay: -12.5s; }

@keyframes mistMove01 { from { transform: translateX(100%); } to { transform: translateX(-100%); } }
@keyframes mistMove02 { from { transform: translateX(0); } to { transform: translateX(-200%); } }
@keyframes mistMove01R { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
@keyframes mistMove02R { from { transform: translateX(-200%); } to { transform: translateX(0); } }

/* ==========================================================================
   § Button (line-pair + clone-text roller)
   ========================================================================== */
.button { display: inline-grid; position: relative; }
.button_block { display: grid; align-items: center; }
.button_line {
  grid-area: 1/1;
  height: 1px;
  background-color: var(--color-white);
  will-change: transform;
}
.button_text { display: grid; overflow: hidden; overflow: clip; padding-block: .1em; }
.button_text > * { grid-area: 1/1; }

/* ==========================================================================
   § Sticky scaffold (vertical label + content)
   ========================================================================== */
.sticky { display: grid; }
.sticky_sub { justify-self: start; }
/* The vertical rail labels (CATALOGUE, INDEX, BROWSE — ERA) are sticky, so they
   ride down the page beside the content and were the other half of the overlap.
   They fade on the same signal as the header chrome. */
html.-scrolledPage .label {
  /* !important is load-bearing here: motion.js reveals these labels with GSAP,
     which writes opacity as an INLINE style, and an inline value beats any
     selector. This is the one place the cascade cannot win on specificity alone. */
  opacity: 0 !important;
  pointer-events: none;
}
.label {
  transition: opacity .45s cubic-bezier(.25, 1, .5, 1);
  position: sticky;
  height: fit-content;
  inline-size: max-content;
  writing-mode: vertical-rl;
}
.paragraph_text { display: grid; }

/* ==========================================================================
   § Home — hero
   ========================================================================== */
.homeHeader { position: relative; height: 100vh; height: 100svh; overflow: hidden; overflow: clip; }
.homeHeader_contents { height: 100%; }
.homeHeader_inner { position: relative; height: 100%; }
.homeHeader_title { position: absolute; color: var(--color-white-pure); } /* the ONLY pure-white element */
/* ==========================================================================
   HERO SLIDESHOW (replaces the old single .homeHeader_bg mesh). 13 curated
   works crossfade behind the wordmark; each is CONTAIN-fit over its own wall
   tone so nothing is ever cropped. Layers inside .homeHero (all abs inset:0):
     .homeHero_slides  (z 0)  the images
     .homeHero_uplight (z 1)  warm from-below glow, tinted by --slide-wall
     .homeHero_scrim   (z 2)  bottom readability scrim (under the wordmark)
   .homeHero itself sits z-index:0 — ABOVE the site's z-index:-1 fluid canvas
   (so the slideshow paints on both the GL and no-GL paths) and BELOW the
   wordmark (.homeHeader_contents is .layer, z-index:1). --slide-wall is seeded
   inline by PHP (first slide) and kept in sync by hero.js on advance. */
.homeHero {
  position: absolute;
  inset: 0;
  z-index: 0;               overflow: hidden;               /* stacking context: above the -1 fluid canvas, below the z-1 wordmark */
  overflow: clip;
  background-color: var(--slide-wall, var(--color-black)); /* wall shows even before any <img> paints */
  /* ── ONE TEMPO for the whole slideshow ──────────────────────────────────
     Every duration/easing below is derived from these four tokens, so the motion
     reads as a single cohesive rhythm — no mismatched times (day, night, mobile,
     desktop all share it). Tune the feel here and nowhere else.               */
  --hero-dwell: 8s;                         /* time a slide is held == its Ken-Burns zoom length (KEEP in step with SLIDE_MS in hero.js) */
  --hero-fade: 1.6s;                        /* THE crossfade length — used by the dissolve AND the uplight */
  --hero-ease: cubic-bezier(.45, 0, .15, 1);/* THE easing — one curve everywhere (gentle in/out) */
  --hero-zoom: 1.16;                        /* Ken-Burns end scale. Softened from 1.30 so each hand-off has almost NO scale pop = smooth. */
}
/* WebGL "Gallery Reel" canvas — fills the hero and fades in once the first texture
   paints (hero-reel.js sets opacity:1). Only shown once WebGL has taken over (the
   .-reel flag); until then it is hidden so the DOM hero (the fallback) shows. When the
   reel IS active the DOM slides + uplight are hidden — the reel is self-lit. */
.homeReel {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: 3;                 /* above the DOM slides/uplight (all inside the hero's own z-context) */
  opacity: 0;                 /* JS fades to 1 on the first painted frame */
  transition: opacity 1.2s ease;
  background: #070706;        /* night: the reel's deep wall until the first frame lands */
  touch-action: manipulation;
}
/* Day renders a LUMINOUS plaster room, so the pre-paint base must be the blush
   wall — a dark base would flash a black box on the paper page before frame 1. */
html[data-theme="day"] .homeReel { background: #F5E8E1; }

/* ── THE BRUSH ──────────────────────────────────────────────────────────────
   Two layers from one photograph, stacked in register: HAIR below, BODY above
   (so the ferrule always hides the root of the hair however far it bends).

   The two transform origins are the whole trick:
     .heroBrush      pivots on the HAIR TIP — the point that touches the painting
                     stays locked under the pointer while the handle swings.
     .heroBrush_hair pivots where the bristles ENTER THE FERRULE, so the hair bends
                     about its root like real hair, instead of sliding.
   Both are measured off the source photograph, not eyeballed.
   Source is ~3x the drawn size, so it stays crisp on any display. */
.heroBrush {
  position: absolute;
  left: 0;
  top: 0;
  width: 24px;
  height: 52.7px;                  /* the photograph's own 71:156 proportion */
  z-index: 4;
  pointer-events: none;
  opacity: 0;
  transform-origin: 0.65px 1.36px; /* the hair tip */
  transition: opacity .22s ease;
  will-change: transform, opacity;
  backface-visibility: hidden;
  filter:
    drop-shadow(0 5px 9px rgba(0, 0, 0, .55))
    drop-shadow(0 1px 0 rgba(240, 216, 150, .16));
}
.heroBrush img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.heroBrush_hair {
  transform-origin: 4.23px 9.28px; /* where the bristles enter the ferrule */
  will-change: transform;
}
.heroBrush.-on { opacity: 1; }

/* Day: it rests on bright plaster, so the shadow is shorter, softer and warmer —
   a heavy black one would read as a sticker pasted on the wall. */
html[data-theme="day"] .heroBrush {
  filter:
    drop-shadow(0 4px 7px rgba(74, 52, 34, .34))
    drop-shadow(0 1px 1px rgba(74, 52, 34, .18));
}

/* Over the ENTIRE hero your pointer is the brush, so the arrow is hidden across the
   whole panel — not merely while a stroke is in progress. The .-brushable flag is
   set by hero-reel.js only when the brush is genuinely running (desktop pointer,
   WebGL up, solver alive); without it the arrow stays, so the no-WebGL fallback and
   touch devices are never left with an invisible pointer. */
.homeHero.-brushable { cursor: none; }

/* Touch has no hover — there is no brush to hold. */
@media (hover: none) { .heroBrush { display: none; } }
.homeHero:not(.-reel) .homeReel { display: none; }         /* no WebGL → hide the empty canvas, DOM hero shows */
.homeHero.-reel .homeHero_slides,
.homeHero.-reel .homeHero_uplight { display: none; }       /* reel active → DOM hero + uplight stand down */
.homeHero_slides { position: absolute; inset: 0; z-index: 0; }
/* The transition. Every slide is stacked. The next image is ALWAYS preloaded and
   already painted SOLID in the background: the incoming .-active slide sits at full
   opacity on a LOWER z-index. The current work then DISSOLVES away on top of it — the
   outgoing .-leaving slide fades opacity 1 → 0 on a HIGHER z-index — revealing the
   already-present next work underneath. So the background behind the dissolve is the
   next painting, NEVER the bare wall, and only ONE layer ever animates (the outgoing's
   opacity), which stays EXTREMELY SMOOTH: will-change:opacity + backface-visibility put
   it on its own GPU layer so the fade never repaints (no jitter, no muddy blend of two
   moving images). The incoming snaps to full opacity instantly (no fade-in) but is
   hidden under the still-opaque outgoing, so what you see is a clean progressive-
   transparency reveal of the next work. Each slide paints its own wall tone (inline bg)
   so the letterbox stays a lit wall. */
.homeHero_slide {
  position: absolute;
  inset: 0;
  z-index: 0;
  opacity: 0;
}
.homeHero_slide.-active {
  opacity: 1;
  z-index: 1;               /* the incoming/current work: full opacity, in the BACKGROUND */
}
.homeHero_slide.-leaving {
  opacity: 0;               /* dissolve target */
  z-index: 2;               /* ON TOP of the active one, so it reveals it as it clears */
  transition: opacity var(--hero-fade) var(--hero-ease);   /* current disappears with progressive transparency over the already-loaded next */
  will-change: opacity;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  pointer-events: none;
}
/* NO CROP: contain shows the WHOLE work inside the full-bleed box; the slack is
   filled by the slide's own wall bg, never a bar and never a cover-crop. */
.homeHero_img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  object-position: center;
  display: block;
  /* RESTING transform = the zoom's END (scale 1.30). So when the -active slide's
     breathe animation is removed on advance, the outgoing image HOLDS at 1.30 and
     dissolves out still zoomed — no snap back to fit ("recompress"). The incoming slide
     resets to scale 1 via the animation's `from` the instant it gains -active, but at
     that instant it sits UNDERNEATH the still-opaque outgoing slide, so that reset is
     never seen; it then zooms from fit as the outgoing dissolves away above it. */
  transform: scale3d(var(--hero-zoom), var(--hero-zoom), 1);
}
/* BREATHE — the active work slowly ZOOMS IN over the dwell (Ken-Burns). Applied to
   the IMG. object-fit:contain keeps the composition; it STARTS at exact contain-fit
   (scale 1 — the whole work shows in its wall-tone frame, matching the site's fit
   format), then glides to scale 1.30 (a strong ~30% zoom) PAST the fit border for a
   real zoom-in; .homeHero{overflow:clip} crops the overflow, resetting to fit on
   each slide change. SMOOTHNESS: LINEAR timing (constant velocity — no front-loaded
   rush that read as "jerky"), and scale3d + backface-visibility + will-change so the
   scale is GPU-composited on its own layer (no per-pixel repaint = no jitter). */
@keyframes homeHeroBreathe {
  from { transform: scale3d(1, 1, 1); }
  to   { transform: scale3d(var(--hero-zoom), var(--hero-zoom), 1); }
}
.homeHero_slide.-active .homeHero_img {
  animation: homeHeroBreathe var(--hero-dwell, 8s) linear both;
  will-change: transform;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}
/* UPLIGHT — a low, WARM glow rising from the bottom, its hue lifted from the
   active slide's wall (--slide-wall) toward a soft gallery-amber, screen-blended
   so it only ever adds light. Subtle by design (never a neon bloom); its centre
   sits below the frame so it washes the lower painting, not the wordmark band. */
.homeHero_uplight {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: var(--hero-up-a, .16);
  background-image: radial-gradient(125% 62% at 50% 116%,
    color-mix(in srgb, var(--slide-wall, #241a05) 42%, #e8b45e) 0%,
    color-mix(in srgb, var(--slide-wall, #241a05) 66%, #e8b45e) 24%,
    transparent 66%);
  mix-blend-mode: screen;
  /* Opacity ONLY (compositor-cheap). The old `background-image` transition
     re-rasterized the color-mix gradient every frame on the CPU — a real stutter
     that ran right through the crossfade. On a slide change the tint now just
     re-resolves once (no per-frame repaint); the dissolve masks the change. */
  transition: opacity var(--hero-fade) var(--hero-ease);
}
/* SCRIM — the readability guarantee for the pure-white wordmark. A bottom-up
   ink gradient, darkest where the wordmark sits (bottom ~15svh) and gone by
   two-thirds up so the painting stays open. Painted ABOVE the uplight so it
   always reasserts darkness under the type. Worst case: even an unusually
   bright slide (L≈.20 in the wordmark band) composites under this to L≈.10,
   putting #fff at ≥6:1 — well past the 3:1 floor for 50px display type. */
.homeHero_scrim {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background-image: linear-gradient(to top,
    rgba(var(--bg-rgb), .62) 0%,
    rgba(var(--bg-rgb), .34) 22%,
    rgba(var(--bg-rgb), .06) 46%,
    rgba(var(--bg-rgb), 0)   66%);
}
/* LIFT — hover/tap. hero.js toggles .homeHero.-lift. The old hover SCALE on the
   slides wrapper was REMOVED: it transitioned in on mouseenter and back out on
   mouseleave, which fought the breathe zoom on the img — so hovering visibly
   "broke" the zoom. Hover now only lifts the uplight and reveals the cursor light,
   neither of which touches the img scale, so the zoom runs smooth whether or not
   the pointer is over the hero. */
.homeHero.-lift .homeHero_uplight { opacity: calc(var(--hero-up-a, .16) + .06); }
/* CURSOR LIGHT — the hero's mouse response. hero.js publishes the pointer
   position (px, rAF-throttled) into --mx/--my; this is a sized warm glow whose
   CENTRE is translated to the cursor. translate3d keeps it on the compositor
   (moves, never repaints), and mix-blend-mode:screen means it only ADDS light —
   so it reads brighter over light passages and barely over dark ones, i.e. it
   adapts to whatever painting is under it, echoing the collection-page fluid.
   z-index:2 = above the images + uplight; screen-blend keeps the wordmark clean.
   Hidden on touch (no hover) and under reduced-motion. */
.homeHero::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 46vmax;
  height: 46vmax;
  margin: -23vmax 0 0 -23vmax;   /* centre the box on its own (0,0) origin */
  z-index: 2;
  pointer-events: none;
  opacity: 0;
  background-image: radial-gradient(closest-side,
    rgba(255, 236, 202, .34) 0%,
    rgba(255, 222, 176, .12) 46%,
    transparent 72%);
  mix-blend-mode: screen;
  transform: translate3d(var(--mx, 50vw), var(--my, 40vh), 0);
  transition: opacity .5s cubic-bezier(.25, 1, .5, 1);
  will-change: transform, opacity;
}
.homeHero.-lift::after { opacity: 1; }
@media (hover: none) { .homeHero::after { display: none; } }


/* ==========================================================================
   § About
   ========================================================================== */
.aboutHeader { position: relative; }
.aboutHeader_bg {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  overflow: clip;
  background-color: var(--sfc-wall, transparent); /* G5 — see .homeHeader_bg */
}
/* G4 — the about hero carried TWO masks stacked (mesh .35 under a .40 veil), so
   the painting arrived at ~14% of itself: the worst "dark smudge" of the three.
   .35 -> .50 and .40 -> .28. Measured on the actual hero (aarksoth…): bone 16px
   lead text over its brightest 3x3 cell lands at 10.15:1 (9.21:1 chroma bound)
   — small text needs 4.5:1.
   HONEST CAVEAT, needs a human eye: BOTH numbers are no-GL numbers. Under
   desktop WebGL the .mesh_fallback <img> is visibility:hidden and the plate is
   drawn on the shared canvas, so the .5 mesh opacity does not reach it, and
   (per the stacking note on .homeHeader_bg) neither does the veil. On that path
   the about hero already renders UNMASKED, where bone lead text over its
   brightest cell measures 4.15:1 (3.11:1 chroma bound). That is a PRE-EXISTING
   GL/no-GL divergence, not one G4 introduced, and closing it means either a
   scrim in BG_FRAGMENT or isolation:isolate on the hero sections — both of
   which would ADD mask on desktop, the opposite of what was asked for.
   Flagged, not silently "fixed". */
.aboutHeader_bg .mesh { opacity: .5; }
.aboutHeader_bg:after {
  content: "";
  position: absolute;
  inset: 0;
  background-color: rgba(var(--bg-rgb), .28);
}
.aboutOutline_row { display: grid; border-block-end: 1px solid rgba(var(--fg-rgb), .12); }


/* ==========================================================================
   § Collection — filters
   ========================================================================== */
/* G4 — the .filters_bg / .filters_bgImg / .filters_bg:after rules that used to
   sit here are GONE with their markup (collection/index.php removed the
   underlay in G3: it drew the SAME work the site-wide ambient behind it was
   already drawing, at a second scale and a second opacity — one painting
   rendered twice as a smeared ghost). .filters_bgImg was also the last stray
   `object-fit: cover` in this stylesheet, i.e. the last full-bleed crop; it is
   deleted rather than converted to contain, because there is nothing left for
   it to select. `isolation` stays on .filters below: the band still needs its
   own stacking context so the ambient/canvas at z-index -1 cannot surface
   through it. */
.filters { position: relative; isolation: isolate; overflow: hidden; overflow: clip; border-block: 1px solid rgba(var(--fg-rgb), .2); }
.filters_row { display: flex; flex-wrap: wrap; }
.filters_field { min-width: 0; }
.filters_label { display: block; opacity: .6; }
.filters_input,
.filters_select {
  width: 100%;
  -webkit-appearance: none;
  appearance: none;
  border: 0;
  border-bottom: 1px solid rgba(var(--fg-rgb), .2);
  background-color: transparent;
  color: var(--color-white);
}
.filters_select {
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath fill='%23d9d7d4' d='M0 2h8L4 7Z'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right center;
  background-size: 8px 8px;
  cursor: pointer;
}
.filters_select option { background-color: var(--color-black); color: var(--color-white); }
.filters_input::placeholder { color: inherit; opacity: .4; }
.filters_input:focus,
.filters_select:focus { border-color: rgba(var(--fg-rgb), .4); }
.filters_foot { display: flex; flex-wrap: wrap; align-items: center; }
.filters_chips { flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: center; }
.filters_chip {
  display: inline-flex;
  align-items: center;
  border-block-end: 1px solid rgba(var(--fg-rgb), .2);
  color: var(--color-white);
  /* S4 — chip enter micro-transition (house dual-tween voice, CSS-only via
     @starting-style so the engine stays motion-free): y 8px→0 + fade, .5s */
  transition: opacity .5s linear, translate .5s cubic-bezier(.16, 1, .3, 1);
}
@starting-style {
  .filters_chip { opacity: 0; translate: 0 8px; }
}
.filters_chip:before {
  content: "";
  flex: none;
  border-radius: 50%;
  background-color: var(--color-yellow);
}
.filters_clear { border-block-end: 1px solid rgba(var(--fg-rgb), .2); }

/* --------------------------------------------------------------------------
   E2 — custom range controls (Era years / Size min–max + cm|in unit toggle).
   Sit UNDER the bucket select inside the Era & Size fields. izanami inputs:
   transparent, hairline underline, Cinzel; the unit chips carry a gold dot on
   the active unit. Gaps/insets live in the PC/SP dimension blocks. Arriving via
   the gallery's collection/#era= / #size= entry pulses the field (.-reveal).
-------------------------------------------------------------------------- */
.filters_rangeHead { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; }
.filters_rangeLabel { display: block; opacity: .6; }
.filters_rangeHint { opacity: .7; }
.filters_rangeRow { display: flex; align-items: center; }
.filters_rangeInput {
  flex: 1 1 0;
  min-width: 0;
  -webkit-appearance: none;
  appearance: none;
  border: 0;
  border-bottom: 1px solid rgba(var(--fg-rgb), .2);
  background-color: transparent;
  color: var(--color-white);
  text-align: center;
}
.filters_rangeInput::placeholder { color: inherit; opacity: .4; }
.filters_rangeInput:focus { border-color: rgba(var(--fg-rgb), .4); }
.filters_rangeSep { flex: none; opacity: .4; }
.filters_rangeApply {
  flex: none;
  border-block-end: 1px solid rgba(var(--fg-rgb), .2);
  color: var(--color-white);
  cursor: pointer;
}
.filters_unit { flex: none; display: inline-flex; align-items: baseline; }
.filters_unitChip {
  display: inline-flex;
  align-items: center;
  border-block-end: 1px solid rgba(var(--fg-rgb), .2);
  color: var(--color-white);
  opacity: .5;
  cursor: pointer;
  transition: opacity .4s;
}
.filters_unitChip.-active { opacity: 1; }
.filters_unitChip:before {
  content: "";
  flex: none;
  border-radius: 50%;
  background-color: var(--color-yellow); /* gold dot — shown on the active unit */
  opacity: 0;
  transition: opacity .4s;
}
.filters_unitChip.-active:before { opacity: 1; }
/* reveal pulse when arriving via #era= / #size= */
.filters_field.-reveal .filters_rangeLabel { animation: sfcRangeReveal 1.6s ease; }
@keyframes sfcRangeReveal {
  0%, 100% { opacity: .6; }
  30% { opacity: 1; color: var(--color-yellow); }
}

/* --------------------------------------------------------------------------
   Filters — collapsed bar + dialog. The inline seven-field row was replaced by
   a compact Search box + a "Filters" trigger; every field now lives inside
   #filtersDialog (a native <dialog>). izanami idiom throughout: transparent-ink
   surfaces, hairline borders, Cinzel labels, a single gold accent. Field-level
   spacing (labels, selects, .filters_range) is inherited from the unscoped
   .filters_* rules in the PC/SP dimension blocks, so the fields look identical
   inside the dialog — only their container changes. Dimensions live in those
   blocks; structure/paint here.
-------------------------------------------------------------------------- */
/* the page bar: Search grows, the Filters button holds its width, one line ---
   the -search override beats the SP `.filters_field.-search { flex:1 1 100% }`
   (0,3,0 > 0,2,0) so the button never wraps below the box on mobile. */
.filters_bar { display: flex; align-items: flex-end; }
.filters_bar .filters_field.-search { flex: 1 1 auto; min-width: 0; }
.filters_open {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid rgba(var(--fg-rgb), .2);
  background-color: transparent;
  color: var(--color-white);
  cursor: pointer;
  transition: border-color .4s;
}
.filters_openGlyph { flex: none; stroke: currentColor; fill: currentColor; opacity: .85; }
.filters_openGlyph line { stroke-width: 1; }
.filters_openText { white-space: nowrap; }
.filters_openCount {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 999px;
  background-color: var(--color-yellow);   /* brass pill — active-filter count */
  color: var(--color-black);
  font-variant-numeric: tabular-nums;
  line-height: 1;
}
.filters_openCount[hidden] { display: none; }

/* the dialog ------------------------------------------------------------- */
/* display:flex is safe on the base rule: the UA `dialog:not([open])` rule
   (specificity 0,1,1) still beats `.filtersDialog` (0,1,0), so the closed
   dialog stays display:none; when [open] it becomes the flex column below.
   Frosted glass — the dark ambient work behind the page blurs/blends through
   rather than a flat opaque panel. */
.filtersDialog {
  opacity: 0;
  display: flex;
  flex-direction: column;
  margin: auto;
  padding: 0;
  overflow: hidden;
  overflow: clip;                 /* the .filtersDialog_body scrolls, not the box */
  border: 1px solid rgba(var(--fg-rgb), .2);
  /* frosted ink: translucent enough for the ambient work to blur through, but
     opaque enough that the .6-opacity labels keep >=4.5:1 even over a worst-case
     bright backdrop (the ::backdrop wash already halves it first). */
  background-color: rgba(var(--bg-rgb), .82);
  color: var(--color-white);
  isolation: isolate;             /* keep the ::before frost behind the content */
  /* Only opacity animates. `all` re-fired the transition on every unrelated
     restyle (the backdrop engine mutates inherited vars on a filter change /
     scroll), which kept `overlay` from settling to `none` — so the CLOSED dialog
     lingered in the top layer and its full-screen backdrop kept eating every tap,
     leaving the whole collection page unusable until some other reset. */
  transition: opacity .4s, display .4s allow-discrete, overlay .4s allow-discrete;
}
/* While the dialog fades OUT ([open] already removed, but the box lingers in the
   top layer for the discrete display/overlay transition) it must NOT intercept
   input — let clicks/taps fall straight through to the page beneath. */
.filtersDialog:not([open]),
.filtersDialog:not([open])::backdrop { pointer-events: none; }
/* The blur lives on a backmost pseudo-layer, NOT on .filtersDialog itself:
   `backdrop-filter` on the dialog element makes it a CONTAINING BLOCK for
   fixed-positioned descendants, which mis-places the re-parented Tags combo
   panel (position:fixed, positioned from viewport coords). On ::before it
   frosts the page behind identically while the dialog box stays containing-
   block-free, so the combo resolves to the viewport as its JS expects. */
.filtersDialog::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  -webkit-backdrop-filter: blur(14px) saturate(1.05);
  backdrop-filter: blur(14px) saturate(1.05);
  pointer-events: none;
}
.filtersDialog[open] { opacity: 1; }
@starting-style { .filtersDialog[open] { opacity: 0; } }
/* readability fallback: engines without backdrop-filter get a near-opaque fill
   so the labels/selects never sit on a see-through, illegible surface. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .filtersDialog { background-color: rgba(var(--bg-rgb), .97); }
}
.filtersDialog::backdrop {
  opacity: 0;
  background-color: rgba(var(--bg-rgb), .5);   /* light wash — the painted backdrop still reads */
  transition: opacity .4s, display .4s allow-discrete, overlay .4s allow-discrete;
}
.filtersDialog[open]::backdrop { opacity: 1; }
@starting-style { .filtersDialog[open]::backdrop { opacity: 0; } }

.filtersDialog_head {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  border-block-end: 1px solid rgba(var(--fg-rgb), .12);
}
.filtersDialog_title { min-width: 0; opacity: 1; }
.filtersDialog_x { flex: none; opacity: .6; cursor: pointer; transition: opacity .4s linear; }
.filtersDialog_xLines { display: grid; place-items: center; aspect-ratio: 1/1; }
.filtersDialog_xLine { grid-area: 1/1; width: 100%; height: 1px; background-color: var(--color-white); }
.filtersDialog_xLine:first-of-type { rotate: 45deg; }
.filtersDialog_xLine:last-of-type { rotate: -45deg; }
/* THE scroll container — min-height:0 lets the flex row bound it; the box above
   clips, so this pane is the only thing that scrolls (mobile sheet + long lists). */
.filtersDialog_body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}
/* .filters_row inside the dialog is a grid (2-col desktop / 1-col mobile),
   overriding its base flex-wrap. Dimensions in the PC/SP blocks. */
.filtersDialog .filters_row { display: grid; align-items: start; }
.filtersDialog_foot {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  border-block-start: 1px solid rgba(var(--fg-rgb), .12);
}
.filtersDialog_foot .filters_clear { flex: none; opacity: .7; }
.filtersDialog_done {
  flex: none;
  border: 1px solid var(--color-yellow);
  background-color: var(--color-yellow);   /* primary — brass */
  color: var(--color-black);
  cursor: pointer;
  transition: opacity .4s;
}

/* ==========================================================================
   § Collection — cards. TWO walls share the .piece anatomy:
   · #grid (collection engine, .gallery_grid WITHOUT .-static) — G2: CSS Grid,
     exactly 3 equal columns on every viewport (Instagram alignment), each
     cell a uniform 1/1 square passe-partout mat (bone tint + hairline) with
     the artwork contain-fit UNCROPPED at its true orientation inside.
   · .gallery_grid.-static (room/category walls) — the original masonry
     column-count rules, cards at their true --ar. Unchanged.
   ========================================================================== */
.piece { break-inside: avoid; }
/* .piece_media is tag-agnostic: <button> (collection engine) AND
   <a> (server-rendered .gallery_grid.-static category grids) share every rule.
   --ar carries the TRUE pixel aspect of the photograph (aspects.json): on the
   .-static walls object-fit:contain is an exact fit (bone tint = pre-load
   placeholder only, fades on .is-loaded); on the collection grid the cell is
   forced 1/1 below and the SAME tint stays on as the mat. --ar keeps being
   emitted either way — the -feature break + static walls consume it, and the
   true-AR DATA (aspects.json) still drives dialog/zoom/room exactly. */
.piece_media {
  display: block;
  position: relative;
  width: 100%;
  aspect-ratio: var(--ar, 3/4);
  overflow: hidden;
  overflow: clip;
  background-color: rgba(var(--fg-rgb), .05);
  border: 0;
  padding: 0;
  text-decoration: none;
  color: inherit;
  cursor: pointer;
  transition: background-color .4s;
}
.piece_media.is-loaded { background-color: transparent; }
/* hairline frame — invisible until card hover (any-hover block) */
.piece_media:after {
  content: "";
  position: absolute;
  inset: 0;
  border: 1px solid rgba(var(--fg-rgb), .25);
  opacity: 0;
  transition: opacity .4s;
  pointer-events: none;
}
.piece_img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  transition: scale .7s cubic-bezier(.25, 1, .5, 1);
}
.piece_media.-noimg {
  display: grid;
  place-content: center;
  border: 1px solid rgba(var(--fg-rgb), .2);
}
.piece_noimgText { opacity: .4; }
.piece_detail { opacity: .55; }
/* G5 — OVERFLOW GUARD. At 3-up on a phone the card column is ~82–105px, so every
   grid/flex child down the chain must be free to shrink below its own content
   size. A grid item's automatic minimum is min-content, which is why a long
   single-word title used to push the whole track (and with it the page) wider
   than the viewport. `min-width:0` releases that floor; body's
   `overflow-wrap:anywhere` (§Base) is what then makes the min-content size of an
   unbreakable word collapse to one character instead of the whole word. Both
   halves are required — either one alone still overflows. */
.piece,
.piece_meta { min-width: 0; }
/* editorial break — one wide work spanning all columns every ~30 cards
   (column-span serves the .-static masonry; the grid ignores it and spans
   via grid-column below). The break keeps its TRUE --ar — it is a break,
   not a cell. */
.piece.-feature {
  column-span: all;
  border-block: 1px solid rgba(var(--fg-rgb), .2);
}
.piece.-feature .piece_media {
  /* exact fit under a 60vh cap: width follows the true aspect */
  width: min(100%, calc(60vh * (var(--ar, 3/4))));
  margin-inline: auto;
}
.piece.-feature .piece_meta { text-align: center; }

/* --------------------------------------------------------------------------
   G2 — Instagram alignment (collection #grid ONLY; .-static walls excluded).
   3 equal columns, rows aligned; every non-feature cell is a 1/1 square mat:
   the bone tint STAYS after load and a resting hairline frames it (the
   hover :after still brightens to .25 on top). The artwork keeps
   object-fit:contain at its true orientation — the mat slack around
   works now sit at their own proportion, top-aligned in the same three columns.
   Gaps live in the PC/SP dimension blocks (1.25vw / .9950248756vw).
-------------------------------------------------------------------------- */
/* G5 — `1fr` is minmax(auto, 1fr): its FLOOR is the item's min-content size, so
   one long unbreakable title could widen a track and blow the page out
   sideways. minmax(0, 1fr) pins the floor at zero — three exactly equal columns
   that can never be pushed apart by their contents, at every viewport. Same
   rendered result as before on desktop (nothing there was hitting the floor);
   it is what makes the 3-up phone grid safe at 320px. */
.gallery_grid:not(.-static) { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  align-items: start; } /* rows stay as they are; a shorter work hangs from the top of its row */
.gallery_grid:not(.-static) .piece { margin-block-end: 0; } /* the grid gap owns the rhythm (features included) */
.gallery_grid:not(.-static) .piece .piece_media {
  /* THE CELL TAKES THE ARTWORK'S OWN PROPORTION. It used to be forced to 1/1 with
     the work fitted inside it, which meant every piece that was not square got
     white letterbox bands — above and below a landscape work, left and right of a
     portrait one. Those bands were the "white frames" around the paintings: not a
     colour problem at all, but the square cell showing through beside the art.
     --ar is the photograph's true pixel aspect (emitted per tile from aspects.json),
     so `contain` is now an EXACT fit and no mat is ever visible. Automatic for any
     new upload — the ratio comes from the image itself. */
  aspect-ratio: var(--ar, 3/4);
  border: 1px solid rgba(var(--fg-rgb), .12); /* hairline, now hugging the work itself */
}
.gallery_grid:not(.-static) .piece .piece_media.is-loaded { background-color: transparent; } /* nothing to mat: the work fills its cell */
/* Phase 3 / warm-gallery (Day). Collection artworks carry baked-in WHITE
   frames/margins that can't be cropped. The resting mat above is a faint INK
   wash — right for the night wall, but on paper it rings those white margins
   with a dark halo. In Day the mat becomes PURE white (--sfc-mat #FFFFFF) so the
   baked margins are indistinguishable from it; the faint ink hairline on
   .piece_media still defines the tile against the blush wall. Night unchanged. */
html[data-theme="day"] .gallery_grid:not(.-static) .piece .piece_media,
html[data-theme="day"] .gallery_grid:not(.-static) .piece .piece_media.is-loaded { background-color: transparent; }
/* --------------------------------------------------------------------------
   FEATURE TILES MADE INERT (owner: uniform grid, no full-width breaks).
   app.js still emits `.piece.-feature` every ~30 cards, but in the collection
   #grid it must render byte-identically to a normal cell: a single 1/1 mat
   cell (the cell rules just above apply to it too now — the `:not(.-feature)`
   guards were dropped), no column span, no editorial hairline/margins/padding,
   no centered caption, and its Playfair-re-voiced title pulled back to the
   card's Cinzel. The global `.piece.-feature` base rules (span/border/media
   width/centering) are left intact for the .-static masonry contract and are
   simply overridden here at grid scope. */
.gallery_grid:not(.-static) .piece.-feature {
  grid-column: auto;   /* was 1 / -1 — a feature is now one uniform cell */
  border-block: 0;     /* was the editorial top/bottom hairline */
  margin-block: 0;     /* the grid gap owns the rhythm, exactly like a cell */
  padding-block: 0;    /* was the PC/SP feature vertical padding */
}
.gallery_grid:not(.-static) .piece.-feature .piece_media { width: 100%; margin-inline: 0; } /* was min(100%,60vh*ar), centered */
.gallery_grid:not(.-static) .piece.-feature .piece_meta  { text-align: start; }             /* was center */
.gallery_grid:not(.-static) .piece.-feature .piece_title { font-family: "Glegoo", Georgia, serif; }      /* re-voice Playfair -> the card's Cinzel */
/* E2 — the artwork HUGS THE TOP of the square mat (the bottom mat varies per
   work, so top-aligning keeps the row of images reading as a shelf). Collection
   #grid only; the contain-fit is unchanged, only its vertical anchor. Feature
   breaks are AR-sized so this is a no-op there. */
.gallery_grid:not(.-static) .piece_img { object-position: top; }

/* --------------------------------------------------------------------------
   G2 — hover ink mask + typewriter (collection #grid, desktop any-hover;
   motion.js §0d injects the element once per card on FIRST hover and owns
   the typing). Flat ink at .92, bottom-anchored, translated fully below the
   media box at rest — .piece_media{overflow:clip} is the clipping container
   — and wiped up on .-masked. It types the whole role-gated record (title,
   artist, then one line per field, size in BOTH units), so its height is
   elastic: at least HALF the cell, at most the whole cell, with motion.js
   locking the measured px height per hover between those bounds. Anything
   past the cap is clipped here, never on the grid. Text starts empty; the
   caret blinks at the clock cadence (500ms on / 500ms off).
   The GL hover-distortion canvas sits at z -1 BEHIND the page, so this DOM
   panel inherently paints over it — the 8% translucency lets the distortion
   ghost through the ink. Touch: no .-masked ever (JS-gated) + hidden in the
   any-hover:none block. Padding dims live in the PC/SP blocks.
-------------------------------------------------------------------------- */
.piece_mask {
  position: absolute;
  inset: auto 0 0 0;
  height: 50%;      /* motion.js overrides with the measured px height */
  min-height: 50%;  /* …clamped to half the cell … */
  max-height: 100%; /* …and to the whole cell */
  /* SCROLL, don't clip: a long record (attribution + Note) that exceeds the cell
     now scrolls INSIDE the panel so the whole note can be read; `auto` shows the
     bar ONLY when it actually overflows. overscroll-behavior keeps the wheel/touch
     in the panel instead of scrolling the grid behind it. */
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  background-color: rgba(var(--bg-rgb), .92); /* --color-black, flat — no gradient */
  transform: translateY(100%);
  transition: transform .7s cubic-bezier(.25, 1, .5, 1); /* house curve, in AND out */
  pointer-events: none;
  text-align: left;
  /* thin themed scrollbar — foreground ink (Day) / bone (Night) at low alpha, so
     it flips with the theme and stays subtle. */
  scrollbar-width: thin;
  scrollbar-color: rgba(var(--fg-rgb), .3) transparent;
}
/* shown on hover; pointer-events ON so the wheel/touch can scroll a long record.
   A plain click still bubbles to #grid's delegated opener (closest('.piece_media')),
   and motion.js's pointerout relatedTarget guard keeps the mask up while scrolling. */
.piece_media.-masked .piece_mask { transform: translateY(0); pointer-events: auto; }
.piece_mask::-webkit-scrollbar { width: 6px; }
.piece_mask::-webkit-scrollbar-track { background: transparent; }
.piece_mask::-webkit-scrollbar-thumb { background-color: rgba(var(--fg-rgb), .3); border-radius: 3px; }
/* the type utilities (.pfd_R20_H100 / .cz_R14_H100) live LATER in this sheet
   at the same specificity and carry line-height:1 — these two qualify with
   .piece_mask so the panel's wrapped lines actually get their air back */
.piece_mask .piece_maskTitle { line-height: 1.3; }
.piece_mask .piece_maskMeta { line-height: 1.55; opacity: 1; color: var(--color-white); }
/* The typed record follows the SAME rules as the dialog's metadata list, because
   it shows the same fields and should not read as a different language:

   · the VALUE carries the body face (Shippori Mincho). The whole block was set in
     Cinzel — a display face whose caps-like letterforms are right for a LABEL and
     wrong for "Acrylic on Canvas" typed out a character at a time.
   · the LABEL keeps Cinzel, in letterspaced caps, at the SAME SIZE as its value.
   · neither is dimmed. Both were faded (label .55 inside a block already at .7,
     so the label landed near 38% of full ink) — hierarchy done by making words
     hard to read, which is the one thing a record being typed out cannot afford. */
.piece_maskLine {
  display: block;
  font-family: "Glegoo", Georgia, serif;
}
.piece_maskLabel {
  font-family: "Glegoo", Georgia, serif;
  text-transform: uppercase;
  letter-spacing: .09em;
  color: var(--ink-label);
  opacity: 1;
  margin-inline-end: .6ch;
}
/* long records (the master sees ~20 fields): tighten before the clip bites */
.piece_mask.-dense .piece_maskTitle { line-height: 1.15; }
.piece_mask.-dense .piece_maskMeta { line-height: 1.35; }
.piece_maskCaret {
  display: inline-block;
  width: 1px;
  height: .9em;
  margin-inline-start: .3ch;
  vertical-align: -.08em;
  background-color: var(--color-white); /* 1px bone caret */
  animation: sfcCaret 1s steps(2, jump-none) infinite; /* 500ms blink — clock cadence */
}
@keyframes sfcCaret {
  from { opacity: 1; }
  to { opacity: 0; }
}
/* --------------------------------------------------------------------------
   G3 — ADAPTIVE HOVER UPLIGHT (collection #grid, desktop any-hover).
   ONE element for the whole page — never one per card. A soft elliptical pool
   of light whose centre sits just BELOW the hovered cell: a plinth/footlight,
   which is how a dark room lights a hung work.
   WHY IT SPILLS: it is painted at z-index -1 in the ROOT stacking context as a
   LATER sibling than the webgl canvas — so it lands ABOVE the ambient plate and
   BELOW every card. The light therefore falls on the WALL around the cell and
   rises through the cell's own translucent 5% bone mat (the slack the contain
   fit leaves around the artwork), which is why it reads as illumination in a
   room and not as a UI glow welded to a box.
   COLOUR: the card's data-glow (that artwork's dominant colour) pushed to one
   fixed high OKLab lightness with its chroma more than halved and capped — a
   colour of LIGHT, not a smear of the paint.
   STRENGTH: --sfc-up-a comes from data-glow-strength (already inverse to the
   work's luminance: dark works get lift, bright ones almost none) under a HARD
   0.12 ceiling. It must never shout.
   TIMING: in .18s, out .42s — light lingers, UI snaps. The pool GLIDES between
   cards on transform (.42s) instead of teleporting. motion.js §0d raises it on
   the same pointerover that starts the ink mask, ~170ms before the typewriter's
   first character, so hovering reads as ONE gesture: the light comes on, then
   the label becomes readable.
   Suppressed on touch (any-hover:none below), during scroll momentum (§0e) and
   under prefers-reduced-motion.
-------------------------------------------------------------------------- */
.sfcUplight {
  position: fixed;
  left: 0;
  top: 0;
  z-index: -1;
  width: var(--sfc-up-w, 0px);
  height: var(--sfc-up-h, 0px);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  background-image: radial-gradient(ellipse 45% 46% at 50% 50%,
    var(--sfc-up-c1, rgba(0, 0, 0, 0)) 0%,
    var(--sfc-up-c2, rgba(0, 0, 0, 0)) 38%,
    var(--sfc-up-c0, rgba(0, 0, 0, 0)) 100%);
  transform: translate3d(var(--sfc-up-x, 0px), var(--sfc-up-y, 0px), 0);
  transition: opacity .42s cubic-bezier(.25, 1, .5, 1),
              transform .42s cubic-bezier(.25, 1, .5, 1),
              visibility 0s linear .42s;
}
.sfcUplight.-on {
  opacity: var(--sfc-up-a, 0);
  visibility: visible;
  transition: opacity .18s cubic-bezier(.25, 1, .5, 1),
              transform .42s cubic-bezier(.25, 1, .5, 1),
              visibility 0s;
}

/* result summary — Cinzel line over a hairline, live-updated by app.js */
.gallery_summary { border-block-end: 1px solid rgba(var(--fg-rgb), .2); opacity: .7; }
.gallery_sentinel { height: 1px; }
.gallery_empty { text-align: center; border-block: 1px solid rgba(var(--fg-rgb), .12); }

/* ==========================================================================
   § Detail dialog (izanami voiceDialog pattern)
   ========================================================================== */
/* --------------------------------------------------------------------------
   G5 — THE DIALOG SCROLLS.
   HONEST ATTRIBUTION FIRST, because the obvious CSS story here is the WRONG
   one. The tempting diagnosis is that .pieceDialog_inner declared COLUMNS ONLY,
   so its single row was implicit and `auto`-sized, grew to its content, and
   left .pieceDialog_meta exactly as tall as its own content — an element that
   is already as tall as its content can never scroll. That is a real failure
   mode, but it is NOT what was happening: `align-content` on a grid is
   `normal` (= stretch), which pins an auto track to a definite container
   height, and .pieceDialog_meta already carried `min-height: 0`. Measured on
   the real rules, before any change here: desktop pane 734px tall holding
   1613px of content, phone pane 352px holding 1399px — a correctly-formed,
   genuinely overflowing scroll container in both cases.
   So the CSS was never why the dialog would not scroll. The cause is the OTHER
   half of this bug, JS-side: Lenis preventDefaults the wheel/touch gesture at
   the document before a nested scroller ever sees it. A sibling agent owns
   that fix — `data-lenis-prevent` on the scrollable node (it is now on both
   .pieceDialog_inner and .pieceDialog_meta in chrome-bottom.php), whose CSS
   partner is the existing `.lenis-smooth [data-lenis-prevent]` rule in
   §Motion support. Without that attribute nothing below changes the outcome.

   What the CSS here DOES do is make the container unambiguous and bound it
   properly, so the pane cannot regress once the gesture reaches it:
     · `grid-template-rows: minmax(0, 1fr)` states the bound explicitly instead
       of leaning on stretch-an-implicit-auto-track, which is correct but
       subtle enough that any future `align-content` or `grid-auto-rows` edit
       would silently break it. `minmax(0, …)`, not plain `1fr`, because `1fr`
       is minmax(auto, 1fr) and its floor is the item's min-content size.
     · `min-height: 0` on every ancestor in the chain (inner → media/meta →
       figure) — one missing link re-inflates the child to its content height.
     · the box itself is bounded (max-height:100dvh) and `overflow: clip`, so
       the DIALOG never becomes a second, competing scroller.
     · overscroll containment + iOS momentum on the pane itself.
   The genuinely load-bearing mobile fix is the `svh` height in the SP block —
   see the note there; that one WAS cutting content off on iOS.
-------------------------------------------------------------------------- */
.pieceDialog {
  opacity: 0;
  margin: auto;
  padding: 0;
  max-width: 100vw;
  max-height: 100vh;
  max-height: 100dvh;
  overflow: hidden;
  overflow: clip; /* the meta pane scrolls — the dialog box itself never does */
  border: 1px solid rgba(var(--fg-rgb), .2);
  background-color: var(--color-black);
  color: var(--color-white);
  /* opacity-only — see the .filtersDialog note; `all` stranded the closed dialog
     in the top layer, blocking the page after it was dismissed. */
  transition: opacity .4s, display .4s allow-discrete, overlay .4s allow-discrete;
}
.pieceDialog[open] { opacity: 1; }
/* fading-out dialog must not block the page */
.pieceDialog:not([open]),
.pieceDialog:not([open])::backdrop { pointer-events: none; }
@starting-style {
  .pieceDialog[open] { opacity: 0; }
}
.pieceDialog::backdrop {
  opacity: 0;
  background-color: rgba(var(--bg-rgb), .85);
  transition: opacity .4s, display .4s allow-discrete, overlay .4s allow-discrete;
}
.pieceDialog[open]::backdrop { opacity: 1; }
@starting-style {
  .pieceDialog[open]::backdrop { opacity: 0; }
}
.pieceDialog_inner {
  position: relative;
  display: grid;
  grid-template-rows: minmax(0, 1fr); /* G5 — the row that used to be implicit+auto */
  height: 100%;
  min-height: 0;
}
.pieceDialog_media { display: grid; grid-template-rows: minmax(0, 1fr) auto; height: 100%; min-height: 0; overflow: hidden; overflow: clip; }
.pieceDialog_figure { min-height: 0; }
.pieceDialog_img { width: 100%; height: 100%; object-fit: contain; }
.pieceDialog_thumbs { display: flex; flex-wrap: wrap; }
.pieceDialog_thumb { border: 1px solid rgba(var(--fg-rgb), .2); overflow: hidden; overflow: clip; }
.pieceDialog_thumb[aria-current=true] { border-color: var(--color-yellow); }
/* G3 — dialog thumbnails showed a cropped centre of each alternate view. They
   now CONTAIN the whole work inside the same 5% bone mat the grid cells use, so
   the slack reads as a passe-partout rather than a hole. */
.pieceDialog_thumbImg {
  width: 100%;
  height: 100%;
  object-fit: contain;
  background-color: rgba(var(--fg-rgb), .05);
}
/* G5 — THE scroll container. min-height:0 lets the grid row bound it;
   overflow-y:auto then has something to do. overflow-x is clipped rather than
   auto so a long unbroken value can never produce a sideways scrollbar inside
   the pane. overscroll-behavior:contain stops the scroll chaining out to the
   page when the pane hits its end; -webkit-overflow-scrolling keeps iOS
   momentum. */
.pieceDialog_meta {
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden; /* `hidden`, not `clip` — pairs with a scrolling axis on every engine */
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}
/* ── Metadata typography — ONE artist-anchored scale, identical to the grid
   card (owner: "Untitled smaller than the artist; same size in grid + dialog").
   Overrides the utility classes on the markup (.pieceDialog_meta .x is 0,2,0 vs
   the utilities' 0,1,0). Fixed px, both breakpoints. Desktop 20/16/14, mobile
   15/12/11. Artist is the emphasis; the title sits smaller beneath it. ── */
.pieceDialog_meta .pieceDialog_kicker {          /* ARTIST NAME — the emphasis */
  font-family: "Glegoo", Georgia, serif;
  font-size: 20px; line-height: 1.2; opacity: 1;
  text-transform: none; letter-spacing: 0;
}
.pieceDialog_meta .pieceDialog_title {           /* TITLE — smaller than the artist */
  font-family: "Glegoo", Georgia, serif;
  font-size: 16px; line-height: 1.25; opacity: 1;
}
.pieceDialog_meta .pieceDialog_data { font-size: 14px; line-height: 1.6; }
@media (max-width: 767px) {
  .pieceDialog_meta .pieceDialog_kicker { font-size: 15px; }
  .pieceDialog_meta .pieceDialog_title  { font-size: 12px; }
  .pieceDialog_meta .pieceDialog_data   { font-size: 11px; }
}
.pieceDialog_row { display: grid; border-block-end: 1px solid rgba(var(--fg-rgb), .12); }

/* METADATA LABEL. It used to be the same 11px as its value with `opacity: .5`
   laid over it — so the only thing separating "Medium" from "Acrylic on Canvas"
   was that the label had been faded to half. On the dark theme that reads as
   grey; on the white theme it collapses to about #8a8782 and the label looks
   both washed out AND smaller than the data it introduces, which is the wrong
   way round for the one word that tells you what you are reading.

   Labels are now distinguished the way type does it — letterspaced caps at a
   slightly smaller size — and rendered in SOLID ink instead of a fade, so they
   stay legible on either ground. Caps and tracking carry the "this is a label"
   signal that the opacity was being asked to carry alone. */
.pieceDialog_row dt,
.aboutOutline_row dt {
  opacity: 1;
  color: var(--ink-label);
  /* SAME SIZE AS ITS VALUE, deliberately. Shrinking the label was the other half
     of the same mistake as fading it: the word that tells you what you are looking
     at ended up the least legible thing in the row. Caps and tracking carry the
     "label" reading on their own — at equal size the label actually sits slightly
     TALLER in cap-height than the sentence-case value beside it, so the hierarchy
     is legible without anything being made harder to read. */
  font-size: 1em;
  text-transform: uppercase;
  letter-spacing: .09em;
}
.pieceDialog_row dd,
.aboutOutline_row dd { color: var(--color-white); }
/* S4 — scale strip (Saffronart `_placement` idea, redrawn in hairlines):
   artwork rectangle at true relative scale beside the 170 cm figure — the
   viewBox is in CENTIMETRES, so proportions are exact for every piece.
   app.js builds/removes it per dialog fill; hidden when no cm data. */
.pieceDialog_scale { margin-block-start: 1rem; }
.pieceDialog_scale svg {
  display: block;
  width: min(10vw, 100%);
  min-width: 96px;
  height: auto;
  max-height: 140px;
  fill: none;
  stroke: var(--color-white);
  stroke-width: 1;
  opacity: .45;
}
.pieceDialog_scaleCaption { color: var(--ink-label); margin-block-start: .5rem; }
.pieceDialog_links { display: flex; flex-wrap: wrap; }
/* #dZoom / #dRoom are <button>s — border/bg reset keeps them identical to <a> links */
.pieceDialog_link {
  border: 0;
  border-block-end: 1px solid rgba(var(--fg-rgb), .4);
  background-color: transparent;
  cursor: pointer;
}
.pieceDialog_nav { display: flex; align-items: center; justify-content: space-between; }
.pieceDialog_navBtn { border-block-end: 1px solid rgba(var(--fg-rgb), .2); }
.pieceDialog_navPos { opacity: .55; }
.pieceDialog_close {
  position: absolute;
  z-index: 1;
  display: inline-grid;
  place-content: center;
}
.pieceDialog_closeLines { display: grid; place-items: center; aspect-ratio: 1/1; }
.pieceDialog_closeLine {
  position: relative;
  grid-area: 1/1;
  width: 100%;
  height: 1px;
  background-color: currentColor;
}
.pieceDialog_closeLine:first-of-type { rotate: 45deg; }
.pieceDialog_closeLine:last-of-type { rotate: -45deg; }
/* ✕ streak hover (izanami retreat-modal ✕) — motion.js injects two inners
   per line and tweens x ±120% + clip-path, 1.1s expo.out, delays ·.055/.11 */
.pieceDialog_closeLine:has(.pieceDialog_closeLineInner) { background-color: transparent; }
.pieceDialog_closeLineInner {
  display: block;
  position: absolute;
  inset: 0;
  background-color: currentColor;
}

/* G4 — the related-rail block (.pieceDialog_related / _relatedTitle /
   _relatedRail / .relatedThumb) is DELETED: B3's injector was removed from
   app.js with the rail itself, so nothing in the live gallery emits that
   markup any more. Its PC/SP dimension rules, its :hover and its
   reduced-motion entries went with it. */

/* ==========================================================================
   § Category / Artists — content-anchored heroes
   (h1 stays bone — only the landing hero is pure white)
   ========================================================================== */
.artistsHeader { position: relative; min-height: 100vh; min-height: 100svh; overflow: hidden; overflow: clip; }
.artistsHeader_bg {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  overflow: clip;
  background-color: var(--sfc-wall, transparent); /* G5 — see .homeHeader_bg */
}
/* G4 — .45 -> .28, with the other two heroes. Measured on the actual hero
   (ls0wzxp…, the "moderns" plate): bone 16px lead text over its brightest 3x3
   cell lands at 9.66:1 (8.64:1 chroma bound), against 10.83:1 before. Same
   no-GL scope note as .homeHeader_bg — unmasked on the GL path this hero still
   measures 7.84:1, so it is safe either way. */
.artistsHeader_bg:after {
  content: "";
  position: absolute;
  inset: 0;
  background-color: rgba(var(--bg-rgb), .28); /* flat veil — not a gradient */
}




.artistsFeatured_rail {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.artistsFeatured_rail::-webkit-scrollbar { display: none; }
.artistCard { flex: none; scroll-snap-align: start; }
.artistCard_img { position: relative; overflow: hidden; overflow: clip; aspect-ratio: 36/55; } /* 36:55 keeps the rail's card widths uniform */
/* The featured-artist card shows one uncropped hero work, TOP-ALIGNED and
   blended straight into the page — no ink ground, no frame, no border. An
   off-ratio work simply leaves transparent space below it (which reads as the
   page), the same "let the image breathe" treatment as the collection grid. */
.artistCard_img img { width: 100%; height: 100%; object-fit: contain; object-position: top; transition: scale .7s cubic-bezier(.25, 1, .5, 1); }
.artistCard_name { display: block; }
.artistCard_meta { display: block; opacity: .55; }

/* ==========================================================================
   § Deck (stacked-plates card media — rooms rail, rooms index, featured artists)
   Up to three plates in one 36:55 house frame: the room's hero in front, the
   curated companions staggered 1.25vw / 2.5vw behind it — izanami offset
   stacks, hairline borders, NO rotation. motion.js slides the front plate up
   2.5% on hover ([data-deck], any-hover only), revealing more of the plate
   beneath. -duo = two plates (featured artists); -solo = degraded single.
   ========================================================================== */
.deck {
  position: relative;
  display: grid;
  aspect-ratio: 36/55; overflow: hidden; /* house card ratio */
  overflow: clip;
}
.deck_layer {
  grid-area: 1/1;
  align-self: start;
  width: 100%;
  overflow: hidden;
  overflow: clip;
  border: 1px solid rgba(var(--fg-rgb), .2);
  /* D3 — solid ink ground (was a 5% bone pre-load tint). With the plate image
     now CONTAIN, an off-ratio work letterboxes; an opaque ink card keeps each
     stacked plate reading as its own hairline-framed card (a transparent
     margin would let the plate behind bleed through) and never a bone box. */
  background-color: var(--color-black);
}
.deck_layer img { width: 100%; height: 100%; object-fit: contain; } /* D3 — full work inside the 36:55 plate (was cover) */
.deck_layer.-top { z-index: 2; will-change: transform; }
.deck_layer.-mid { z-index: 1; }


/* ==========================================================================
   § Home (gallery) — browse panels (E1)
   The landing's three ways in — Artist · Era · Size — as imageless, text-
   forward panels on the mouse-lit shaded field (webgl fluid dye-glow, home
   only). Each panel is a flat bone-alpha TONE BLOCK over ink (no painting),
   hairlined, with the izanami vertical Cinzel .label and a gold accent bar
   that wakes on hover. 5 colours + bone alphas, hairlines, NO gradients/
   shadows/radii-except-circles; dims live in the two 768px media blocks.
   ========================================================================== */
.galleryBrowse { position: relative; }
.browsePanel {
  position: relative;
  border-block-start: 1px solid rgba(var(--fg-rgb), .2);      /* hairline divider */
  background-color: rgba(var(--fg-rgb), .03);    /* flat tone block over ink */
  transition: background-color .5s;
}
.galleryBrowse .browsePanel:last-child { border-block-end: 1px solid rgba(var(--fg-rgb), .2); }
/* gold accent bar at the panel's leading edge — wakes on hover (any-hover) */
.browsePanel:before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 2px;
  background-color: var(--color-yellow);
  opacity: 0;
  transition: opacity .5s;
}
.browsePanel_main { min-width: 0; }
.browsePanel_note { opacity: .7; }
.browsePanel_hint { display: inline-flex; align-items: center; opacity: .45; }

/* artist list — hairlined rows: Playfair name + Cinzel count, deep-linked */
.artistList { display: grid; width: 100%; }
.artistList_link {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: baseline;
  border-block-end: 1px solid rgba(var(--fg-rgb), .12);
  color: var(--color-white);
  transition: border-color .4s;
}
.artistList_count { opacity: .5; transition: color .4s, opacity .4s; }
.artistList_item.-all .artistList_name { opacity: .7; }

/* chip strip — Cinzel, count in parens, gold dot wakes on hover */
.browseChips { display: flex; flex-wrap: wrap; align-items: center; }
.browseChip {
  display: inline-flex;
  align-items: center;
  border-block-end: 1px solid rgba(var(--fg-rgb), .2);
  color: var(--color-white);
  white-space: nowrap;
  transition: border-color .4s;
}
.browseChip:before {
  content: "";
  flex: none;
  border-radius: 50%;
  background-color: var(--color-yellow);
  opacity: 0; /* 4px dot — hover only (any-hover block) */
  transition: opacity .4s;
}
/* the closing "All →" chip of a browse band: full bone like its siblings
   (see the mosaic-contrast note above) — the arrow is what marks it */

/* ==========================================================================
   § Artists — A–Z index
   ========================================================================== */
.artistsIndex_letter { border-block-start: 1px solid rgba(var(--fg-rgb), .2); }
.artistsIndex_link {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: baseline;
  border-block-end: 1px solid rgba(var(--fg-rgb), .12);
}
.artistsIndex_count { opacity: .5; }


/* ==========================================================================
   § 404
   ========================================================================== */
.notFound {
  position: relative;
  display: grid;
  align-items: center;
  grid-template-columns: 100%; min-height: 100vh; /* full-bleed child keeps the page gutters */
  min-height: 100svh;
  text-align: start;
  overflow: hidden;
  overflow: clip;
}

/* ==========================================================================
   § Link hover hooks (B3 animates: clone rollers + scaleX underlines)
   ========================================================================== */
/* [data-roller] marks the hover host (the link) — its layout stays untouched;
   the inner clone stack is [data-clone]: grid, children stacked 1/1, clipped */
[data-clone] { display: grid; overflow: hidden; overflow: clip; padding-block: .1em; }
[data-clone] > * { grid-area: 1/1; }
[data-underline] {
  display: block;
  width: 100%;
  height: 1px;
  background-color: var(--color-white);
  transform: scaleX(0);
  transform-origin: left center;
  will-change: transform;
}

/* ==========================================================================
   § Zoom viewer (fullscreen pan/pinch — B3 injects; JS owns the transform)
   ========================================================================== */
.zoomViewer {
  position: fixed;
  inset: 0;
  z-index: 95; /* above --loader-index 90 */
  background-color: rgba(var(--bg-rgb), .96);
  overflow: hidden;
  overflow: clip;
  touch-action: none;
  overscroll-behavior: contain;
  cursor: grab;
}
.zoomViewer[hidden],
.roomView[hidden] { display: none; }
.zoomViewer.is-panning,
.zoomViewer.is-panning .zoomViewer_img { cursor: grabbing; }
.zoomViewer_img {
  position: absolute;
  top: 0;
  left: 0;
  width: auto;
  height: auto;
  max-width: none; /* escape the global img{width:100%;max-width:100%} */
  transform-origin: 0 0;
  will-change: transform;
  user-select: none;
  -webkit-user-drag: none;
  cursor: grab;
}
/* HUD/caption are JS-injected — cz_R14 metrics inlined (sizes in the media blocks) */
.zoomViewer_hud {
  position: absolute;
  left: 50%;
  translate: -50% 0;
  opacity: .7;
  text-align: center;
  pointer-events: none;
  font-family: "Glegoo", Georgia, serif;
  font-optical-sizing: auto;
  font-weight: 400;
  font-style: normal;
  line-height: 1;
  font-variant-numeric: tabular-nums; /* zoom % ticks without jitter */
}
.zoomViewer_close,
.roomView_close {
  position: absolute;
  z-index: 1;
  display: inline-grid;
  place-content: center;
  cursor: pointer;
}
.zoomViewer_closeLines,
.roomView_closeLines { display: grid; place-items: center; aspect-ratio: 1/1; }
.zoomViewer_closeLine,
.roomView_closeLine {
  grid-area: 1/1;
  width: 100%;
  height: 1px;
  background-color: currentColor;
}
.zoomViewer_closeLine:first-of-type,
.roomView_closeLine:first-of-type { rotate: 45deg; }
.zoomViewer_closeLine:last-of-type,
.roomView_closeLine:last-of-type { rotate: -45deg; }
.zoomViewer_nav {
  position: absolute;
  top: 50%;
  translate: 0 -50%;
  border: 0;
  border-block-end: 1px solid rgba(var(--fg-rgb), .2);
  background-color: transparent;
  cursor: pointer;
}
/* full-resolution control — lives INSIDE the zoom viewer and swaps the
   viewer's own source for the untransformed original (viewer.js §zFull). It
   is a <button>, never a link: no href, no target, no title — the image URL
   never reaches the address bar or a tooltip. */
.zoomViewer_full {
  position: absolute;
  z-index: 1;
  border: 0;
  border-block-end: 1px solid rgba(var(--fg-rgb), .2);
  background-color: transparent;
  color: inherit;
  opacity: .7;
  cursor: pointer;
}
.zoomViewer_full[disabled] { cursor: default; opacity: .4; }

/* ==========================================================================
   § Room view (true-scale wall — B3 sizes .roomView_art via --room-px-per-cm)
   ========================================================================== */
.roomView {
  position: fixed;
  inset: 0;
  z-index: 95;
  display: grid;
  place-content: center;
  justify-items: center;
  background-color: var(--color-black);
  overflow: hidden;
  overflow: clip;
}
.roomView_wall {
  position: relative;
  aspect-ratio: 16/7;
  background-color: var(--color-black); /* same ink — depth = hairline floor + framed art */
}
.roomView_wall:after {
  content: "";
  position: absolute;
  top: 78%;
  left: 0;
  right: 0;
  height: 1px;
  background-color: rgba(var(--fg-rgb), .4); /* floor line */
}
.roomView_art { position: absolute; border: 1px solid rgba(var(--fg-rgb), .2); }
/* D3 — contain: the wall box is sized from the artwork's real cm (B3, via
   --room-px-per-cm) so the box aspect already equals the work's, making this
   an exact fit; contain only guarantees a pixel-AR rounding never crops. True
   scale is unaffected (box dims still come from cm). */
.roomView_art img { display: block; width: 100%; height: 100%; object-fit: contain; }
svg.roomView_silhouette {
  position: absolute;
  bottom: 22%; /* feet on the floor line */
  width: auto;
  height: calc(170 * var(--room-px-per-cm, 1px));
  opacity: .25;
  fill: none;
  stroke: var(--color-white);
  stroke-width: 1;
}
.roomView_caption {
  opacity: .7;
  font-family: "Glegoo", Georgia, serif;
  font-optical-sizing: auto;
  font-weight: 400;
  font-style: normal;
  line-height: 1;
}

/* ==========================================================================
   § Motion support (scoped so no-JS still renders everything)
   ========================================================================== */
html.-motion [data-reveal],
html.-motion [data-reveal-lines] { opacity: 0; }
html.-motion .loader_titleText { /* hook — motion.js char-splits and animates */ }
html.-webgl .mesh_fallback { visibility: hidden; }
/* -wait input freeze — loader + veil transitions (motion.js toggles on <html>) */
html.-wait { cursor: wait; }
html.-wait * { pointer-events: none !important; user-select: none !important; }
.lenis, .lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
html.lenis.lenis-stopped { overflow: hidden; overflow: clip; }
.lenis.lenis-smooth iframe { pointer-events: none; }

/* ==========================================================================
   § PC — typography + every desktop dimension
   PC canvas 1600px: type = clamp(N*.75px, (N/16)vw, N*1.125px)
   ========================================================================== */
@media (min-width: 768px) {
  /* -- type system -- */
  .cz_R14_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: clamp(10.5px, .875vw, 15.75px); line-height: 1; }
  .cz_R16_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: clamp(12px, 1vw, 18px); line-height: 1; }
  .cz_R18_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: clamp(13.5px, 1.125vw, 20.25px); line-height: 1; }
  .cz_R30_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: clamp(22.5px, 1.875vw, 33.75px); line-height: 1; }
  .pfd_R20_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: clamp(15px, 1.25vw, 22.5px); line-height: 1; }
  .pfd_R30_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: clamp(22.5px, 1.875vw, 33.75px); line-height: 1; }
  .pfd_R40_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: clamp(30px, 2.5vw, 45px); line-height: 1; }
  .pfd_R50_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: clamp(37.5px, 3.125vw, 56.25px); line-height: 1; }
  .sm_R16_H200 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: clamp(12px, 1vw, 18px); line-height: 2; }
  .sm_R16_H240 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: clamp(12px, 1vw, 18px); line-height: 2.4; }

  /* -- shell -- */
  .inner { padding-inline: 5vw; }

  /* -- loader -- */
  .loader_inner { padding-inline: 17.5vw; }
  .loader_contents { padding-block-end: 2.5vw; }
  .loader_progress { bottom: 2.5vw; }

  /* -- header -- */
  .header_inner { padding-block-start: 1.875vw; padding-inline: 5vw; }
  .header_block { grid-template-columns: 67.5vw 1fr; margin-inline: clamp(-11.8125px, -.65625vw, -7.875px); } /* count column removed — wordmark · menu */
  .header_logo { padding: clamp(7.875px, .65625vw, 11.8125px); }
  .openTrigger, .themeToggle { gap: 1.25vw; padding: clamp(7.875px, .65625vw, 11.8125px); }
  .openTrigger_circleInner { width: clamp(3px, .25vw, 4.5px); }
  .openTrigger_text, .themeToggle_label { padding-block-end: .625vw; }

  /* -- nav -- */
  .nav_inner { padding-inline: 5vw; }
  .nav_link { padding: clamp(11.25px, .9375vw, 16.875px) .375vw; }
  .nav_linkLine { margin-block-start: .625vw; }
  .nav_linkCircle { left: -2.5vw; width: .25vw; height: .25vw; }
  .nav_subList { gap: 1.25vw; margin-block: 1.25vw; margin-inline-start: 12.5vw; }
  .nav_subLink { padding: .375vw; }
  .nav_foot { margin-block-start: 6.25vw; padding-block: 2.5vw 2.625vw; }

  /* -- aside -- */
  .aside { padding-block-end: 2.5vw; }
  .aside_block { grid-template-columns: repeat(36, 1fr); }
  .aside_copyright { grid-column: 1/7; }
  .aside_count { grid-column: 1/9; }   /* sits where ©2026 was (left) */
  .aside_scroll { grid-column: 34/37; }

  /* -- footer -- */
  .footer { padding-block: 6.25vw; }
  .footer_head { gap: 1.25vw; margin-block-end: 6.25vw; }
  .footer_nextLink:after { margin-block-start: .625vw; }
  .footer_foot { grid-template-columns: repeat(36, 1fr); }
  .footer_copyright { grid-column: 1/7; }
  .footer_pageTop { grid-column: 34/37; margin-block-end: -.625vw; padding: .625vw; }

  /* -- sticky scaffold -- */
  .sticky { grid-template-columns: 8.75vw 1fr; }
  .label { top: clamp(147.75px, 12.3125vw, 221.625px); }

  /* -- buttons -- */
  .button { padding-block: 1.25vw; padding-inline: 1.25vw 0; }
  .button_block { grid-template-columns: 5vw 1fr; gap: 1.25vw; padding-inline-end: 1.25vw; }
  .button_line { width: 5vw; }

  /* -- home hero -- */
  .homeHeader_title { left: 17.5vw; bottom: 15vh; bottom: 15svh; }
  .mist_item { width: 160vw; }

  /* -- about story -- */
  .aboutStory { padding-block: 12.5vw 18.75vw; }
  .aboutStory_descriptions { max-width: 36em; margin-block-start: 1.875vw; }
  .aboutStory_description + .aboutStory_description { margin-block-start: 1.25vw; }


  /* -- about -- */
  .aboutHeader { padding-block: 23.8125vw 12.5vw; }
  .aboutHeader_titleNumber { margin-block-end: 1.25vw; }
  .aboutHeader_lead { max-width: 36em; margin-block-start: 1.875vw; }
  .aboutOutline { padding-block-end: 18.75vw; }
  .aboutOutline_row { grid-template-columns: 12.5vw 1fr; padding-block: 1.25vw; }
  .aboutOutline_button { margin-block-start: 3.75vw; margin-inline-start: -1.25vw; }

  /* -- collection header -- */
  .galleryHeader { padding-block: 23.8125vw 6.25vw; }
  .galleryHeader_titleNumber { margin-block-end: 1.25vw; }
  .galleryHeader_lead { max-width: 36em; margin-block-start: 1.875vw; }

  /* -- filters -- */
  .filters { padding-block: 2.5vw; }
  .filters_row { gap: 2.5vw 1.25vw; }
  .filters_field { flex: 1 1 11.25vw; }
  .filters_field.-search { flex: 2 1 22.5vw; }
  .filters_label { margin-block-end: .625vw; }
  .filters_input, .filters_select { padding-block: .625vw; }
  .filters_select { padding-inline-end: 1.25vw; }
  .filters_foot { margin-block-start: 1.875vw; gap: 1.25vw 2.5vw; }
  .filters_chips { gap: .625vw 1.25vw; }
  .filters_chip { gap: .625vw; padding-block: .375vw; }
  .filters_chip:before { width: clamp(3px, .25vw, 4.5px); height: clamp(3px, .25vw, 4.5px); }
  .filters_clear { padding-block: .375vw; }
  /* E2 — custom range controls */
  .filters_range { margin-block-start: 1.25vw; }
  .filters_rangeHead { gap: .625vw 1.25vw; }
  .filters_rangeRow { gap: 1.25vw; margin-block-start: .625vw; }
  .filters_rangeInput, .filters_rangeApply { padding-block: .625vw; }
  .filters_unit { gap: 1.25vw; }
  .filters_unitChip { gap: .375vw; padding-block: .375vw; }
  .filters_unitChip:before { width: clamp(3px, .25vw, 4.5px); height: clamp(3px, .25vw, 4.5px); }
  /* collapsed bar + Filters button */
  .filters_bar { gap: 1.5vw; }
  .filters_open { gap: .5rem; padding: .9vw 1.6vw; }
  .filters_openGlyph { width: 1rem; height: 1rem; }
  .filters_openCount { min-width: 1.35rem; height: 1.35rem; padding-inline: .4rem; font-size: 11px; }
  /* the dialog — centered modal */
  .filtersDialog { width: min(640px, 92vw); max-height: min(86vh, 44rem); }
  .filtersDialog_head { padding: 1.25rem 1.5rem; }
  .filtersDialog_x { width: 2rem; height: 2rem; padding: .5rem; }
  .filtersDialog_xLines { width: 1rem; }
  .filtersDialog_body { padding: 1.5rem; }
  .filtersDialog .filters_row { grid-template-columns: 1fr 1fr; grid-auto-flow: row dense; gap: 1.75rem 1.5rem; }
  .filtersDialog .filters_field.-range { grid-column: 1 / -1; }
  .filtersDialog_foot { padding: 1rem 1.5rem; }
  .filtersDialog_done { padding: .625rem 1.75rem; }

  /* -- grid + cards -- */
  .gallery { padding-block: 3.75vw 12.5vw; }
  .gallery_summary { margin-block-end: 2.5vw; padding-block-end: .9375vw; }
  .gallery_grid { column-count: 3; column-gap: 1.25vw; } /* .-static masonry (grid display ignores multicol) */
  .gallery_grid:not(.-static) { gap: 1.25vw; } /* G2 — tight izanami grid gap, both axes */
  .piece { margin-block-end: 3.75vw; }
  .piece_meta { padding-block-start: .9375vw; }
  .piece_artist { margin-block-start: .625vw; }
  .piece_detail { margin-block-start: .3125vw; }
  .piece_mask { padding: 1.25vw; } /* G2 — ink panel text inset */
  .piece_maskMeta { margin-block-start: .625vw; }
  .piece_mask.-dense .piece_maskMeta { font-size: clamp(9px, .75vw, 13.5px); } /* long records */
  /* -feature is inert on the grid (see §Collection cards): its box/margins are
     zeroed at base and its meta/artist/detail now inherit the cell spacing
     above. Only the Playfair-30 title needs pulling back to the card's cz_R16. */
  .gallery_grid:not(.-static) .piece.-feature .piece_title { font-size: clamp(12px, 1vw, 18px); line-height: 1; }
  .gallery_empty { padding-block: 12.5vw; }

  /* -- dialog -- */
  .pieceDialog { width: min(90vw, 1400px); height: min(92vh, 900px); }
  .pieceDialog_inner { grid-template-columns: 1fr minmax(20rem, 26rem); }
  .pieceDialog_figure { padding: 2.5vw; }
  .pieceDialog_thumbs { gap: .625vw; padding: 0 2.5vw 2.5vw; }
  .pieceDialog_thumb { width: 3.75vw; height: 3.75vw; }
  .pieceDialog_meta { padding: 2.5vw; }
  .pieceDialog_title { margin-block: .625vw 1.875vw; }
  .pieceDialog_row { grid-template-columns: 7.5rem 1fr; padding-block: .75rem; }
  .pieceDialog_links { gap: 1.875vw; margin-block: 1.875vw; }
  .pieceDialog_link { padding-block-end: .375vw; }
  .pieceDialog_nav { gap: 1.25vw; }
  .pieceDialog_navBtn { padding-block: .375vw; }
  .pieceDialog_close { top: 1.25vw; right: 1.25vw; width: 2.5vw; height: 2.5vw; padding: .625vw; }
  .pieceDialog_closeLines { width: 1.25vw; }

  /* -- artists hero -- */
  .artistsHeader { padding-block: 23.8125vw 6.25vw; }
  .artistsHeader_titleNumber { margin-block-end: 1.25vw; }
  .artistsHeader_lead { grid-column: 2 / -1; max-width: 36em; margin-block-start: 1.875vw; }


  /* -- featured-artist rail -- */
  .artistsFeatured { padding-block: 3.75vw 6.25vw; }
  .artistsFeatured_rail { margin-block-start: 2.5vw; gap: 1.25vw; }
  .artistCard { width: 22.5vw; }
  .artistCard_name { margin-block-start: .9375vw; }
  .artistCard_meta { margin-block-start: .3125vw; }

  /* -- browse panels (landing): generous vertical rhythm, weight on the text -- */
  .browsePanel { padding-block: 6.25vw; }
  .browsePanel_note { margin-block-start: 1.25vw; }
  .artistList { margin-block-start: 2.5vw; }
  .artistList_link { padding-block: .9375vw; gap: 2.5vw; }
  .browsePanel_hint { gap: .625vw; margin-inline-start: .625vw; }

  .browseChips { margin-block-start: 2.5vw; gap: .9375vw 1.875vw; }
  .browseChip { gap: .625vw; padding-block: .375vw; }
  .browseChip:before { width: clamp(3px, .25vw, 4.5px); height: clamp(3px, .25vw, 4.5px); }

  /* -- deck plates: 1.25vw / 2.5vw offset stagger inside the 36:55 frame -- */
  .deck_layer { height: calc(100% - 2.5vw); }
  .deck_layer.-mid { translate: 0 1.25vw; }
  .deck_layer.-back { translate: 0 2.5vw; }
  .deck.-duo .deck_layer { height: calc(100% - 1.25vw); }
  .deck.-duo .deck_layer.-back { translate: 0 1.25vw; }
  .deck.-solo .deck_layer { height: 100%; }


  /* -- artists index -- */
  .artistsIndex { padding-block-end: 12.5vw; }
  .artistsIndex_letter { padding-block: 1.875vw; }
  .artistsIndex_link { padding-block: .9375vw; }


  /* -- aside clocks (36-col HUD grid) -- */
  .aside_times { grid-column: 17/28; }

  /* -- footer leader (line 22.5vw | text — izanami block proportions) -- */
  .footer_nextLink:has(.footer_nextLinkLine) { grid-template-columns: 22.5vw 1fr; gap: 1.25vw; }

  /* -- 404 -- */
  .notFound_description { max-width: 36em; margin-block-start: 1.875vw; }
  .notFound_button { margin-block-start: 2.5vw; margin-inline-start: -1.25vw; }


  /* -- zoom viewer -- */
  .zoomViewer_hud { bottom: 1.875vw; font-size: clamp(10.5px, .875vw, 15.75px); }
  .zoomViewer_close, .roomView_close { top: 1.25vw; right: 1.25vw; width: 2.5vw; height: 2.5vw; padding: .625vw; }
  .zoomViewer_closeLines, .roomView_closeLines { width: 1.25vw; }
  .zoomViewer_nav { padding-block: .375vw; }
  .zoomViewer_nav.-prev { left: 2.5vw; }
  .zoomViewer_nav.-next { right: 2.5vw; }
  .zoomViewer_full { top: 1.25vw; left: 2.5vw; padding-block: .375vw; }

  /* -- room view -- */
  .roomView_wall { width: min(80vw, 1200px); }
  .roomView_caption { margin-block-start: 1.25vw; font-size: clamp(10.5px, .875vw, 15.75px); }
}

/* four columns on wide desktop — .-static masonry walls only (the collection
   grid stays exactly 3, Instagram-style, on every viewport) */
@media (min-width: 1200px) {
  .gallery_grid.-static { column-count: 4; }
}

/* ==========================================================================
   § Hover refinements
   ========================================================================== */
@media (any-hover: hover) {
  .piece_media:hover .piece_img { scale: 1.04; }
  .artistCard:hover .artistCard_img img { scale: 1.04; } /* featured rail — dim painted hover */
  /* S4 — on the .-static room / category walls, while the single GL hover mesh
     covers a card its DOM img yields: the mesh (canvas, z −1) shows through the
     card box and runs the crop-zoom shader; the CSS scale is suppressed so the
     handoff back to DOM on release lands exactly at scale 1 (webgl.js
     HoverMeshItem). C1 — SCOPED to .-static: the collection #grid never runs
     the mesh (its hover voice is the DOM ink mask, motion.js §0d), so its
     .piece_img must always stay visible on hover. */
  .gallery_grid.-static .piece_media.-glHover .piece_img { visibility: hidden; scale: 1 !important; }
  .piece:hover .piece_media:after { opacity: 1; }
  .filters_input:hover, .filters_select:hover { border-color: rgba(var(--fg-rgb), .4); }
  .filters_chip:hover, .filters_clear:hover { border-color: var(--color-yellow); }
  .filters_open:hover { border-color: var(--color-yellow); }
  .filtersDialog_x:hover { opacity: 1; }
  .filtersDialog_done:hover { opacity: .85; }
  /* E2 — custom range controls */
  .filters_rangeInput:hover { border-color: rgba(var(--fg-rgb), .4); }
  .filters_rangeApply:hover, .filters_unitChip:hover { border-color: var(--color-yellow); }
  .browseChip:hover { border-color: var(--color-yellow); }
  .browseChip:hover:before { opacity: 1; } /* the 4px gold dot wakes */
  /* E1 — the browse panels answer the pointer: the tone block brightens a touch
     and a gold accent bar wakes at the leading edge */
  .browsePanel:hover { background-color: rgba(var(--fg-rgb), .05); }
  .browsePanel:hover:before { opacity: 1; }
  .artistList_link:hover { border-color: rgba(var(--fg-rgb), .4); }
  .artistList_link:hover .artistList_count { color: var(--color-yellow); opacity: 1; }
  .pieceDialog_link:hover, .pieceDialog_navBtn:hover { border-color: var(--color-yellow); }
  .pieceDialog_thumb:hover { border-color: rgba(var(--fg-rgb), .4); }
  .zoomViewer_nav:hover { border-color: var(--color-yellow); }
  .zoomViewer_full:not([disabled]):hover { border-color: var(--color-yellow); opacity: 1; }
}

/* touch: no hover lines (izanami kill-list); tap = open, no reveal layer.
   G2: the ink mask never runs on touch either — motion.js §0d gates on
   any-hover + pointerType; this hides any residue belt-and-braces. */
@media (any-hover: none) {
  [data-underline],
  .piece_mask,
  .sfcUplight { display: none; } /* G3 — no uplight without a real pointer */
}

/* ==========================================================================
   § SP — typography + every mobile dimension
   SP canvas 402px: any length = (N/4.02)vw, unclamped
   ========================================================================== */
@media (max-width: 767px) {
  /* -- type system --
     SIZED IN vmin, NOT vw. The canvas is a 402px-wide phone, so on a phone HELD
     UPRIGHT vmin and vw are the same number and every size below is unchanged to
     the pixel. Rotate that phone, though, and vw becomes the 844px LONG side: the
     labels measured 19.3px against a 12px tile title — text on a short screen was
     rendering half again larger than on a tall one, purely because the reference
     edge had swapped. vmin always measures the short side, so type now keeps one
     physical size in both orientations, which is what "the same on any device"
     has to mean. Layout dimensions below stay in vw deliberately — those SHOULD
     follow the width they are filling. */
  .SP_cz_R11_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 2.7363184080vmin; line-height: 1; }
  .SP_cz_R12_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 2.9850746269vmin; line-height: 1; }
  .SP_cz_R24_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 5.9701492537vmin; line-height: 1; }
  .SP_pfd_M14_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 3.4825870647vmin; line-height: 1; }
  .SP_pfd_M20_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 4.9751243781vmin; line-height: 1; }
  .SP_pfd_R26_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 6.4676616915vmin; line-height: 1; }
  .SP_pfd_R30_H100 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 7.4626865672vmin; line-height: 1; }
  .SP_ms_R12_H200 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 2.9850746269vmin; line-height: 2; }
  .SP_sm_R12_H240 { font-family: "Glegoo", Georgia, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 2.9850746269vmin; line-height: 2.4; }

  /* -- shell -- */
  .inner { padding-inline: 10.1990049751vw; }

  /* -- loader -- */
  .loader_inner { padding-inline: 20.1492537313vw; }
  .loader_contents { padding-block-end: 7.9601990050vw; }
  .loader_progress { bottom: 7.9601990050vw; }

  /* -- header -- */
  .header_inner { padding-block-start: 1.8656716418vw; padding-inline: 5.2238805970vw; }
  .header_block { grid-template-columns: 1fr auto; column-gap: 2.4875621891vw; margin-inline: -3.1094527363vw; } /* count column removed — wordmark · menu */
  .header_logo { padding: 3.1094527363vw; }
  /* gap was 5.7213930348vw (21.5px at 375) — that single value is what made the
     MENU / Day-Night stacks 63px tall against a 34px wordmark. Dropped to the
     2.4875621891vw step already in this scale (9.3px), which pulls the VISIBLE
     content to ~27px, comfortably under the logo. The padding is deliberately
     LEFT ALONE: it is invisible but it is the tap target, and trimming it to
     match would take the buttons under the 44px touch minimum. */
  .openTrigger, .themeToggle { gap: 2.4875621891vw; padding: 3.1094527363vw; }
  .openTrigger_circleInner { width: .9950248756vw; }
  .openTrigger_text, .themeToggle_label { padding-block-end: 1.2437810945vw; }
  /* MOBILE only — the MENU trigger + Day/Night toggle hide/show on scroll with the
     SAME `.header.-min` logic as the logo (fade + lift out on scroll-down, return at
     the top / on scroll-up). Desktop has no such rule, so there the nav stays put and
     is always reachable. */
  /* (the controls' hide-on-scroll now lives in the GLOBAL block as `-scrolled`,
     so phones and desktop share one rule and one trigger) */

  /* -- nav -- */
  .nav_inner { padding-inline: 5.2238805970vw; }
  .nav_link { padding: 2.4875621891vw 1.2437810945vw; }
  .nav_linkLine { margin-block-start: 1.2437810945vw; }
  .nav_linkCircle { left: -4.9751243781vw; width: .9950248756vw; height: .9950248756vw; }
  .nav_subList { gap: 2.4875621891vw; margin-block: 2.4875621891vw; margin-inline-start: 12.4378109453vw; }
  .nav_subLink { padding: 1.2437810945vw; }
  .nav_foot { margin-block-start: 12.9353233831vw; padding-block: 4.9751243781vw 7.4626865672vw; }

  /* -- aside: only "scroll" survives on SP -- */
  .aside { padding-block-end: 7.9601990050vw; }
  .aside_block { grid-template-columns: 100%; }
  /* the live match count SURVIVES on SP now (it's the whole point of the aside on
     the collection page); ©2026 is gone and the clock stays hidden. */
  .aside_copyright, .aside_times { display: none; }

  /* -- footer -- */
  .footer { padding-block: 12.4378109453vw; }
  .footer_head { gap: 2.4875621891vw; margin-block-end: 12.4378109453vw; }
  .footer_nextLink:after { margin-block-start: 1.2437810945vw; }
  .footer_foot { grid-template-columns: 100%; row-gap: 2.4875621891vw; }

  /* -- sticky scaffold -- */
  /* BUG2 — single-column stack: label row above the title row. The gap belongs
     on the GRID (row-gap), unambiguous and collapse-proof. The label's own
     margin-block-end resolved to the block axis of writing-mode:vertical-rl —
     i.e. HORIZONTAL — so it shoved the label sideways and never separated it
     from the title. Moved to .sticky row-gap; applies to every browse panel. */
  .sticky { grid-template-columns: 100%; row-gap: 4.9751243781vw; }
  .label { top: 19.9004975124vw; } /* G5 — 30px -> 20px */

  /* -- buttons -- */
  .button { padding-block: 4.9751243781vw; padding-inline: 4.9751243781vw 0; }
  .button_block { grid-template-columns: 19.9004975124vw 1fr; gap: 4.9751243781vw; padding-inline-end: 4.9751243781vw; }
  .button_line { width: 19.9004975124vw; }

  /* -- home hero -- */
  .homeHeader_title { left: 20.1492537313vw; bottom: 15vh; bottom: 15svh; }
  .mist_item { width: 160vw; }

  /* -- about story -- */
  .aboutStory { padding-block: 24.8756218905vw 37.3134328358vw; }
  .aboutStory_descriptions { max-width: 36em; margin-block-start: 4.9751243781vw; }
  .aboutStory_description + .aboutStory_description { margin-block-start: 2.4875621891vw; }


  /* -- about -- */
  .aboutHeader { padding-block: 44.7761194030vw 24.8756218905vw; }
  .aboutHeader_titleNumber { margin-block-end: 2.4875621891vw; }
  .aboutHeader_lead { max-width: 36em; margin-block-start: 4.9751243781vw; }
  .aboutOutline { padding-block-end: 37.3134328358vw; }
  .aboutOutline_row { grid-template-columns: 24.8756218905vw 1fr; padding-block: 2.9850746269vw; }
  .aboutOutline_button { margin-block-start: 7.4626865672vw; margin-inline-start: -4.9751243781vw; }

  /* -- collection header --
     G5 — VERTICAL RHYTHM RETUNE (owner: "too much dead space, it reads loose
     and unfinished"). The SP canvas is 402px, so these vw values are px at
     that width: the hero's top pad was 180px of which only ~65px is fixed-
     header clearance — 115px of nothing above the first word. 180 -> 120 keeps
     ~55px of deliberate air under the header and returns a full thumb of
     scroll. Bottom 60 -> 30, and the lead's own top margin 20 -> 15, so the
     hgroup reads as one block instead of three drifting lines. */
  .galleryHeader { padding-block: 29.8507462687vw 7.4626865672vw; }
  .galleryHeader_titleNumber { margin-block-end: 2.4875621891vw; }
  .galleryHeader_lead { max-width: 36em; margin-block-start: 3.7313432836vw; }

  /* --------------------------------------------------------------------------
     G5 — THE BROWSING APPARATUS ALIGNS TO THE MASTHEAD.
     `.inner` indents content by 10.199vw on SP — twice the header's and nav's
     own 5.224vw gutter. That deep editorial indent is right for a hero, but it
     was spending 20% of a phone screen on side margin around a 3-up grid, which
     is both the "dead space" the owner is reporting and the reason the cards had
     no room left for their captions. The FILTER band and the GRID (the utility
     half of the page) now sit on the masthead's own margin, so they line up
     with the logo and the menu trigger above them; the hero keeps the deep
     indent, so the editorial voice is untouched.
     Worth ~13% of card width: at 390px the cell goes 100.9px -> 113.8px, which
     is what pays for the type step below. Scoped to these two sections — every
     other `.inner` on every other page is unchanged.
  -------------------------------------------------------------------------- */
  .filters .inner,
  .gallery .inner { padding-inline: 5.2238805970vw; }

  /* -- filters -- (G5: 20px -> 15px on the band pad, the row gutter and the foot) */
  .filters { padding-block: 3.7313432836vw; }
  .filters_row { gap: 3.7313432836vw 2.4875621891vw; }
  .filters_field { flex: 1 1 37.3134328358vw; }
  .filters_field.-search { flex: 1 1 100%; }
  .filters_label { margin-block-end: 1.4925373134vw; }
  .filters_input, .filters_select { padding-block: 2.4875621891vw; }
  .filters_select { padding-inline-end: 3.7313432836vw; }
  .filters_foot { margin-block-start: 3.7313432836vw; gap: 2.4875621891vw 4.9751243781vw; }
  .filters_chips { gap: 1.2437810945vw 2.4875621891vw; }
  .filters_chip { gap: 1.2437810945vw; padding-block: .9950248756vw; }
  .filters_chip:before { width: .9950248756vw; height: .9950248756vw; }
  .filters_clear { padding-block: .9950248756vw; }
  /* E2 — custom range controls */
  .filters_range { margin-block-start: 2.4875621891vw; }
  .filters_rangeHead { gap: 1.2437810945vw 2.4875621891vw; }
  .filters_rangeRow { gap: 2.4875621891vw; margin-block-start: 1.4925373134vw; }
  .filters_rangeInput, .filters_rangeApply { padding-block: 2.4875621891vw; }
  .filters_unit { gap: 2.4875621891vw; }
  .filters_unitChip { gap: 1.2437810945vw; padding-block: .9950248756vw; }
  .filters_unitChip:before { width: .9950248756vw; height: .9950248756vw; }
  /* collapsed bar + Filters button */
  .filters_bar { gap: 2.4875621891vw; }
  .filters_open { gap: 1.4925373134vw; padding: 2.4875621891vw 3.7313432836vw; }
  .filters_openGlyph { width: 3.9800995025vw; height: 3.9800995025vw; }
  .filters_openCount { min-width: 4.7263681592vw; height: 4.7263681592vw; padding-inline: 1.4925373134vw; font-size: 11px; }
  /* the dialog — full-screen frosted sheet (a touch more opaque than desktop:
     a full-bleed sheet over the busy grid needs more solidity to stay legible) */
  .filtersDialog {
    width: 100%; height: 100%;
    max-width: 100vw; max-height: 100vh; max-height: 100dvh;
    margin: 0; border: 0;
    background-color: rgba(var(--bg-rgb), .86);
  }
  .filtersDialog_head { padding: 4.9751243781vw 5.2238805970vw; }
  .filtersDialog_x { width: 9.9502487562vw; height: 9.9502487562vw; padding: 2.4875621891vw; }
  .filtersDialog_xLines { width: 4.9751243781vw; }
  .filtersDialog_body { padding: 5.2238805970vw; }
  .filtersDialog .filters_row { grid-template-columns: 1fr; gap: 6.2189054726vw; }
  /* stacked footer: primary (Show works) full-width on top, quiet Clear all below */
  .filtersDialog_foot { flex-direction: column-reverse; align-items: stretch; gap: 3.7313432836vw; padding: 4.9751243781vw 5.2238805970vw 6.2189054726vw; }
  .filtersDialog_done { width: 100%; text-align: center; padding-block: 3.7313432836vw; }
  .filtersDialog_foot .filters_clear { text-align: center; padding-block: 2.4875621891vw; }

  /* -- grid + cards --
     G5 — section rhythm: the gallery used to end on 100px of nothing before a
     footer that opens with 50px of its own. 30/100 -> 20/60. */
  .gallery { padding-block: 4.9751243781vw 14.9253731343vw; }
  .gallery_summary { margin-block-end: 2.9850746269vw; padding-block-end: 1.4925373134vw; }

  /* --------------------------------------------------------------------------
     G5 — THREE UP ON PHONES, EVERY PATH.
     The collection engine's #grid already resolved to `display:grid` +
     3 equal columns here (the structural rule near §Collection — cards), but
     the multicol declaration on the bare `.gallery_grid` selector still said
     TWO — so any wall that is not the grid (a `.-static` masonry wall, or the
     grid on a build where `display:grid` did not take) fell back to 2-up. Both
     paths now say three, and the grid's own track list is restated here as
     minmax(0,1fr) so nothing downstream can widen a column.
     The column gutter stays at 4px@402 (the G2 izanami value — proportionally
     the desktop 20px/1440 gutter). The ROW gutter is broken out slightly wider
     (6px) because it now separates one card's caption from the next card's
     artwork, which needs more air than a caption needs from its own image.
     Net vertical per row is still 12px LESS than before (see .piece_meta).
  -------------------------------------------------------------------------- */
  .gallery_grid { column-count: 3; column-gap: .9950248756vw; } /* .-static masonry (grid display ignores multicol) */
  .gallery_grid:not(.-static) {
    grid-template-columns: repeat(2, minmax(0, 1fr)); /* mobile 2-up (owner-confirmed; desktop stays 3-up) */
    gap: 1.4925373134vw .9950248756vw; /* row 6px · column 4px @402 */
  }
  .piece { margin-block-end: 3.7313432836vw; } /* 30 -> 15px (masonry/feature paths; grid cells are 0) */

  /* G5 — CARD METADATA, RE-FITTED FOR A ~90px COLUMN.
     At 3-up the caption block was taller than a third of the artwork above it:
     15px between image and title, then 5 and 4 between the three lines, against
     a desktop card where the same gaps are 3.2% / 2.1% / 1.1% of the card
     width. Scaled onto the phone column those are ~3px / ~2px / ~1px; 6/2/2
     lands close while staying tappable and legible. */
  .piece_meta { padding-block-start: 1.4925373134vw; }
  .piece_artist { margin-block-start: .4975124378vw; }
  .piece_detail { margin-block-start: .4975124378vw; }
  .piece_mask { padding: 2.4875621891vw; } /* G2 — narrow mouse-driven windows only (any-hover gated) */
  .piece_maskMeta { margin-block-start: 1.2437810945vw; }
  .piece_mask.-dense .piece_maskMeta { font-size: 2.3383084577vmin; } /* long records — vmin, so rotating cannot inflate it */
  /* Features are uniform single cells now (see base .piece.-feature block), so
     they take the SAME tight spacing as every other tile — no roomy full-width
     rhythm any more. */
  .gallery_grid:not(.-static) .piece.-feature { margin-block-end: 0; padding-block: 0; }
  .gallery_grid:not(.-static) .piece.-feature .piece_meta   { padding-block-start: 1.4925373134vw; }
  .gallery_grid:not(.-static) .piece.-feature .piece_artist { margin-block-start: .4975124378vw; }
  .gallery_grid:not(.-static) .piece.-feature .piece_detail { margin-block-start: .4975124378vw; }
  .gallery_empty { padding-block: 14.9253731343vw; }

  /* --------------------------------------------------------------------------
     G5 — CARD TYPOGRAPHY: THE DESKTOP HIERARCHY, SCALED TO 3-UP.
     Desktop card (466px wide at the 1600 canvas): title cz 16 · artist pfd 20 ·
     detail cz 14 — i.e. the ARTIST is the biggest thing on the card, the title
     sits at .80 of it and the detail at .70. On the phone the card was carrying
     12 / 14 / 11 (ratios .86 / 1 / .79) at 2-up widths, so at 3-up it was both
     out of proportion AND far too large for a ~90px column.
     These restore the desktop ratios — .79 / 1 / .71 against desktop's
     .80 / 1 / .70 — on the SP house formula (N/4.02)vw, so they scale with the
     page like every other SP value:
       artist 12px@402  (2.9850746269vw — the SP_cz_R12 step)
       title   9.5px@402 (2.3631840796vw)
       detail  8.5px@402 (2.1144278607vw)
     line-height moves off the H100 utilities' 1 because at this width these
     lines WRAP, and wrapped 9px Cinzel at line-height 1 collides. Same
     reasoning (and the same qualify-by-parent trick) as the .piece_mask
     line-height fixes above.
     Scoped to the collection grid and to non-feature cells only.

     THE ONE DELIBERATE DEVIATION FROM THE SP CONVENTION. Everything else in
     this block is unclamped (N/4.02)vw. These three carry a `max()` FLOOR,
     because a pure vw caption on a 3-up grid falls off a cliff on small
     phones: measured at 320px the detail line landed at 6.77px, which is not
     small — it is unreadable. The floors are the same 9 / 11.3 / 8 ratio the
     vw values carry (.80 / 1 / .71), and all three cross over within 2px of
     each other (~379px viewport), so the hierarchy holds exactly through the
     transition and nothing steps out of proportion. Above ~379px — i.e. on
     the 402 design canvas and every modern phone from the iPhone 12 up — the
     floors are inert and the pure vw scale is what renders.
  -------------------------------------------------------------------------- */
  /* Fixed px, not vw — the vw scale read unevenly on small phones. One
     artist-anchored scale, identical to the dialog (owner): artist 15 · title
     12 · detail 11. Artist is the emphasis; title sits smaller beneath it. */
  .gallery_grid:not(.-static) .piece:not(.-feature) .piece_artist { font-size: 15px; line-height: 1.2;  }
  .gallery_grid:not(.-static) .piece:not(.-feature) .piece_title  { font-size: 12px; line-height: 1.25; }
  .gallery_grid:not(.-static) .piece:not(.-feature) .piece_detail { font-size: 11px; line-height: 1.4;  }
  /* Bound the caption. Two lines each is enough for every real record and caps
     what a pathological title can do to the row height; the ellipsis is the
     honest signal that the dialog holds the rest. `overflow:hidden` (not clip)
     is required — the ellipsis does not render under clip. Long single words
     already break rather than spill (body: overflow-wrap:anywhere). */
  .gallery_grid:not(.-static) .piece:not(.-feature) .piece_title,
  .gallery_grid:not(.-static) .piece:not(.-feature) .piece_artist,
  .gallery_grid:not(.-static) .piece:not(.-feature) .piece_detail {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    overflow: hidden;
    min-width: 0;
  }
  /* The DETAIL (size) caption now carries the inch conversion appended by the
     format change — "{medium} · {year} · H × W cm (H′ × W′ in)". Long medium
     names push that tail past the 2-line clamp above, hiding the "(… in)".
     Measured over the live 48-work grid at 2-up / 375px: 40 captions fit 2 lines,
     6 need 3 (e.g. "Oil & Plaster on Canvas …"), and 1 needs 4 — the extreme
     "Watercolour on Bituman Paper pasted on Plywood · 2000 · 87.8 × 114.7 cm
     (34.6 × 45.2 in)". Clamp the DETAIL line to 4 so the inches always show on
     every real caption; title + artist stay clamped at 2. Inherits the
     -webkit-box / overflow:hidden from the block above and only raises the
     clamp — same specificity, later in source order so it wins. Self-gating: a
     line-clamp caps the MAXIMUM lines, it never reserves space, so the 40 short
     captions stay 2 lines tall and only the 7 long ones use the extra room; the
     row grows to its tallest cell while every image top stays aligned (grid
     cells top-align their content). Not theme-scoped — identical Day and Night. */
  .gallery_grid:not(.-static) .piece:not(.-feature) .piece_detail {
    -webkit-line-clamp: 4;
    line-clamp: 4;
  }
  /* Top-align on mobile too, to match desktop (owner). The earlier `center`
     override was tuned for the cramped 3-up cell; at 2-up the top alignment
     reads clean and consistent with PC. Not a crop — fit stays `contain`;
     object-position only decides where the letterbox slack sits (now bottom). */
  .gallery_grid:not(.-static) .piece_img { object-position: top; }

  /* -- dialog --
     G5 — height in `svh`, not `vh`. This is the one change in the dialog that
     was fixing a measurable loss of content rather than hardening something
     already correct. On iOS `vh` resolves against the LARGE viewport (URL bar
     collapsed), so a 92vh dialog is taller than what is actually on screen
     whenever the bar is showing, and the bottom of the box — the Previous /
     Next row — sat under the browser chrome, unreachable. `svh` is the small
     viewport, so 92svh always fits whatever the chrome is doing; the global
     max-height:100dvh is the hard backstop. (Not verifiable in a desktop
     viewport, which has no dynamic chrome: reasoned from the unit definitions,
     and worth a look on a real handset.) */
  .pieceDialog { width: min(90vw, 1400px); height: min(92vh, 900px); height: min(92svh, 900px); }
  /* both rows stated as minmax(0, …) so neither can be pushed open by its
     content — the caption row must be free to be SMALLER than what it holds,
     which is the precondition for it scrolling at all (see the G5 block at
     §Detail dialog for why that was never the actual blocker). 55 -> 50svh
     buys the caption pane back ~40px, which is what puts the Zoom row below
     the artwork on screen without scrolling. */
  .pieceDialog_inner { grid-template-columns: 100%; grid-template-rows: minmax(0, 50vh) minmax(0, 1fr); grid-template-rows: minmax(0, 50svh) minmax(0, 1fr); }
  .pieceDialog_figure { padding: 4.9751243781vw; }
  .pieceDialog_thumbs { gap: 1.2437810945vw; padding: 0 4.9751243781vw 4.9751243781vw; }
  .pieceDialog_thumb { width: 9.9502487562vw; height: 9.9502487562vw; }
  /* --------------------------------------------------------------------------
     G5 — ZOOM DIRECTLY UNDER THE ARTWORK, IN CSS ONLY.
     The DOM (chrome-bottom.php, owned elsewhere) is:
         .pieceDialog_inner > [ .pieceDialog_media , .pieceDialog_meta ]
         .pieceDialog_meta > [ kicker, title, dl#dData, .pieceDialog_links,
                               (.pieceDialog_inquire, injected), .pieceDialog_nav ]
     CSS cannot reparent, so the links row cannot literally leave .pieceDialog_meta
     — but it does not need to: .pieceDialog_meta is the pane that sits
     IMMEDIATELY BELOW .pieceDialog_media (image + thumb strip) on this
     single-column phone layout, so the FIRST thing in that pane is the first
     thing under the artwork. Making the pane a flex column turns `order` on,
     and `order:-1` lifts the whole links row to the top of it; #dZoom then
     takes `order:-1` inside that row so Zoom reads first. The nav row is pinned
     last with `order:1` so it stays at the bottom even though app.js injects
     the inquiry block after it in DOM order.
     `flex: 0 0 auto` on the children is required: in a scrolling flex column
     the default flex-shrink:1 would squash the rows instead of overflowing
     them, and a pane whose content has been squashed to fit has nothing to
     scroll. A hairline under the links row marks it as an affordance bar
     rather than a stray strip of text.
  -------------------------------------------------------------------------- */
  .pieceDialog_meta { display: flex; flex-direction: column; padding: 4.9751243781vw; }
  .pieceDialog_meta > * { flex: 0 0 auto; min-width: 0; }
  .pieceDialog_title { margin-block: 1.2437810945vw 4.9751243781vw; }
  .pieceDialog_row { grid-template-columns: 6rem 1fr; padding-block: .625rem; }
  .pieceDialog_links {
    order: -1;
    gap: 2.4875621891vw 4.9751243781vw;
    margin-block: 0 4.9751243781vw;
    padding-block-end: 3.7313432836vw;
    border-block-end: 1px solid rgba(var(--fg-rgb), .12);
  }
  #dZoom { order: -1; }              /* Zoom leads the row */
  .pieceDialog_link { padding-block-end: .9950248756vw; }
  .pieceDialog_nav { order: 1; gap: 2.4875621891vw; } /* order:1 — Previous/Next always closes the pane */
  .pieceDialog_navBtn { padding-block: .9950248756vw; }
  .pieceDialog_close { top: 2.4875621891vw; right: 2.4875621891vw; width: 9.9502487562vw; height: 9.9502487562vw; padding: 2.4875621891vw; }
  .pieceDialog_closeLines { width: 4.9751243781vw; }

  /* -- artists hero -- */
  .artistsHeader { padding-block: 44.7761194030vw 14.9253731343vw; }
  .artistsHeader_titleNumber { margin-block-end: 2.4875621891vw; }
  .artistsHeader_lead { max-width: 36em; margin-block-start: 4.9751243781vw; }



  /* -- featured-artist rail -- */
  .artistsFeatured { padding-block: 7.4626865672vw 14.9253731343vw; }
  .artistsFeatured_rail { margin-block-start: 4.9751243781vw; gap: 2.4875621891vw; }
  .artistCard { width: 60vw; }
  .artistCard_name { margin-block-start: 2.4875621891vw; }
  .artistCard_meta { margin-block-start: 1.2437810945vw; }

  /* -- browse panels (landing): SP rhythm; chips wrap (flex) — no 2-row scroll -- */
  .browsePanel { padding-block: 14.9253731343vw; }
  .browsePanel_note { margin-block-start: 2.4875621891vw; }
  .artistList { margin-block-start: 4.9751243781vw; }
  .artistList_link { padding-block: 2.4875621891vw; gap: 4.9751243781vw; }
  .browsePanel_hint { gap: 1.2437810945vw; margin-block-start: 2.4875621891vw; }

  /* BUG3 — the 2-row horizontal-scroll grid CLIPPED era/size labels and left a
     large dead vertical gap between its two rows. Fall back to the base
     flex-wrap: chips flow left-to-right and wrap onto as many rows as needed,
     each keeping its FULL label (chips are white-space:nowrap), no clipping, no
     dead gap, no horizontal viewport overflow (longest label ~200px << phone). */
  .browseChips {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    margin-block-start: 4.9751243781vw;
    gap: 2.4875621891vw 4.9751243781vw;
  }
  .browseChip { gap: 1.2437810945vw; padding-block: .9950248756vw; }
  .browseChip:before { width: .9950248756vw; height: .9950248756vw; }

  /* -- deck plates (SP twins of 1.25vw / 2.5vw @1600 = 20px / 40px) -- */
  .deck_layer { height: calc(100% - 4.9751243781vw); }
  .deck_layer.-mid { translate: 0 2.4875621891vw; }
  .deck_layer.-back { translate: 0 4.9751243781vw; }
  .deck.-duo .deck_layer { height: calc(100% - 2.4875621891vw); }
  .deck.-duo .deck_layer.-back { translate: 0 2.4875621891vw; }
  .deck.-solo .deck_layer { height: 100%; }


  /* -- artists index -- */
  .artistsIndex { padding-block-end: 24.8756218905vw; }
  .artistsIndex_letter { padding-block: 4.9751243781vw; }
  .artistsIndex_link { padding-block: 2.4875621891vw; }


  /* -- footer leader -- */
  .footer_nextLink:has(.footer_nextLinkLine) { grid-template-columns: 19.9004975124vw 1fr; gap: 2.4875621891vw; }

  /* -- 404 -- */
  .notFound_description { max-width: 36em; margin-block-start: 4.9751243781vw; }
  .notFound_button { margin-block-start: 4.9751243781vw; margin-inline-start: -4.9751243781vw; }


  /* -- zoom viewer (nav drops to the bottom corners) -- */
  .zoomViewer_hud { bottom: 2.4875621891vw; font-size: 2.7363184080vw; }
  .zoomViewer_close, .roomView_close { top: 2.4875621891vw; right: 2.4875621891vw; width: 9.9502487562vw; height: 9.9502487562vw; padding: 2.4875621891vw; }
  .zoomViewer_closeLines, .roomView_closeLines { width: 4.9751243781vw; }
  .zoomViewer_nav { top: auto; translate: none; bottom: 7.9601990050vw; padding-block: .9950248756vw; }
  .zoomViewer_nav.-prev { left: 5.2238805970vw; }
  .zoomViewer_nav.-next { right: 5.2238805970vw; }
  .zoomViewer_full { top: 2.4875621891vw; left: 5.2238805970vw; padding-block: .9950248756vw; }

  /* -- room view -- */
  .roomView_wall { width: 92vw; }
  .roomView_caption { margin-block-start: 2.4875621891vw; font-size: 2.7363184080vw; }
}

/* ==========================================================================
   § Reduced motion
   (viewers + room stay fully usable — only transitions/animations are killed)
   ========================================================================== */

/* =============================================================================
   § aboutTriptych — layout + no-WebGL fallback (AUDIT-3 referral)
   With WebGL: webgl.js imposes its own 300vh sticky scroll; these rules give the
   section a sane document footprint either way. Without WebGL/JS: static first
   image + first caption only.
============================================================================= */
.aboutTriptych { position: relative; }
.aboutTriptych_sticky { position: relative; }
.aboutTriptych .mesh { position: relative; display: block; overflow: hidden; overflow: clip; }
.aboutTriptych .mesh_fallback { width: 100%; height: 100%; object-fit: contain; display: block; } /* D3 — whole work shown (was cover) */
.aboutTriptych_caption { opacity: .55; }
html:not(.-webgl) .aboutTriptych_caption:not([data-caption-index="1"]) { display: none; }

@media (min-width: 768px) {
  .aboutTriptych { padding-block: 12.5vw; }
  .aboutTriptych .mesh { width: 60vw; margin-inline: auto; aspect-ratio: 16 / 10; }
  .aboutTriptych_caption { text-align: center; padding-block-start: 1.25vw; }
}
@media (max-width: 767px) {
  .aboutTriptych { padding-block: 14.925vw; }
  .aboutTriptych .mesh { width: 89.552vw; margin-inline: auto; aspect-ratio: 4 / 5; }
  .aboutTriptych_caption { text-align: center; padding-block-start: 2.4875vw; }
}

@media (prefers-reduced-motion: reduce) {
  .piece_img { transition: none; }
  .filters_chip { transition: none; } /* S4 chip enter micro-transition off */
  .filters_unitChip, .filters_unitChip:before { transition: none; } /* E2 unit toggle */
  .filters_field.-reveal .filters_rangeLabel { animation: none; } /* E2 reveal pulse off */
  .artistCard_img img { transition: none; }
  .piece_media,
  .piece_media:after { transition: none; }
  /* G2 — the ink mask FADES in place of the wipe; motion.js §0d fills the
     full text instantly (no typing) and the caret never shows */
  .piece_mask { transform: none; opacity: 0; transition: opacity .4s; }
  .piece_media.-masked .piece_mask { opacity: 1; }
  .piece_maskCaret { display: none; }
  /* G3 — the hover uplight is suppressed ENTIRELY (motion.js §0e also refuses
     to raise it), and the ambient wall/guard stop easing (§0e snaps instead) */
  .sfcUplight { display: none; }
  /* HERO SLIDESHOW — under reduced motion the ZOOM (scale, vestibular) is removed,
     but the slideshow STILL cycles with a gentle OPACITY crossfade (no scale, no
     movement). This keeps every device — including phones with Reduce Motion on —
     showing the WHOLE collection instead of a frozen slide 1. Each work rests at
     contain-fit (whole piece, no static crop); hero.js keeps the timer running. */
  .homeHero_slide.-active .homeHero_img { animation: none; }   /* no zoom */
  .homeHero_img { transform: none; }                           /* whole work at fit — no static crop */
  .homeHero_slides { transition: none; }
  .homeHero.-lift .homeHero_slides { transform: none; }
  .homeHero::after { display: none; }   /* cursor light off under reduced-motion */
  .mist_item { animation: none; } /* covers the reversed 404 mist too */
  .pieceDialog,
  .pieceDialog::backdrop { transition: none; }
  .filtersDialog,
  .filtersDialog::backdrop { transition: none; }
  .filters_open, .filtersDialog_x, .filtersDialog_done { transition: none; }
  .zoomViewer_img,
  .roomView_art { transition: none; }
  .artistsFeatured_rail,
  .browseChips { scroll-behavior: auto; }
  .browseChip,
  .browseChip:before { transition: none; }        /* chip hovers snap */
  .browsePanel,
  .browsePanel:before,
  .artistList_link,
  .artistList_count { transition: none; }         /* E1 panel hovers snap */
}

/* ---- Content protection (deterrents) -------------------------------------
   assets/js/protect.js blocks the right-click menu, image drag, and the
   save / view-source / print / devtools shortcuts. Those are all DESKTOP
   routes. On touch devices a long-press still raised the native "Save image"
   sheet, which is the easiest copy route of all — these rules close it, and
   restate the drag block in CSS so it holds even if the script is blocked.

   HONEST LIMIT: this raises the bar against casual copying; it is not a lock.
   Anything a browser renders can ultimately be extracted, and the images are
   delivered from a public CDN. The REAL protection is that the id list is no
   longer enumerable without a session (see gallery/json.php + .htaccess).

   Text selection is disabled for READING surfaces only. Form controls stay
   selectable or the search box and Field Console become unusable, and the
   master keeps full selection so the owner can still copy their own data. */
img,
.piece_media,
.pieceDialog_media {
  -webkit-touch-callout: none;
  -webkit-user-drag: none;
  user-drag: none;
}

.piece_meta,
.pieceDialog_meta,
.piece_mask {
  -webkit-user-select: none;
  user-select: none;
}

input, textarea, select,
.pieces-role-admin .piece_meta,
.pieces-role-admin .pieceDialog_meta {
  -webkit-user-select: text;
  user-select: text;
}

/* ==========================================================================
   § Landscape phones — short viewports
   A phone rotated to landscape is typically 844-932px WIDE, so it matches the
   `min-width: 768px` desktop block above while having only ~360-430px of
   HEIGHT. Every size in that block is derived from vw — and rotating the phone
   is precisely what made vw larger — so the header chrome GREW at the moment it
   had least room, ending up taller than the wordmark beside it. (The narrower
   landscape phones, e.g. 667x375, fall in the SP block instead and are worse
   still: its 2.7363vw label becomes ~18px.)

   The cure is to stop sizing the header off the long axis. Here every header
   dimension comes from vh, so the chrome tracks the SHORT side and shrinks when
   the viewport is short no matter how wide it is. This block is last in the file
   on purpose: source order alone lets it win over both media blocks, so nothing
   above needs !important or reordering.

   Scope is deliberately narrow — the fixed header only. The hero, the slides and
   everything below the fold are untouched.
   ========================================================================== */
@media (orientation: landscape) and (max-height: 500px) {
  /* (The Day/Night <-> MENU gap is NOT set here — it is 60% smaller on every
     viewport, from the base .header_controls rule up in § Header.) */

  /* Label + glyph. font-size is set on .themeToggle itself because the button
     carries the type class and its sun/moon is sized in em (1.16em), so the
     glyph follows the label down automatically. */
  .openTrigger_text,
  .themeToggle { font-size: clamp(8px, 2.2vh, 10.5px); }

  /* Internal [glyph / label] stack — the two must stay identical or the labels
     stop lining up, which is the whole point of the mirrored structure. */
  .openTrigger,
  .themeToggle { gap: clamp(3px, 1.1vh, 6px); padding: clamp(3px, 1vh, 6px); }
  .openTrigger_text,
  .themeToggle_label { padding-block-end: clamp(2px, .7vh, 4px); }

  /* MENU's dot and the toggle's glyph row — the two "heads" that must match. */
  .openTrigger_circleInner { width: clamp(2.5px, .8vh, 3.5px); }
  .themeToggle_icon { height: clamp(2.5px, .8vh, 3.5px); }

  /* The header's own top inset was 1.875vw (~15.8px) — a large bite out of a
     390px-tall viewport. */
  .header_inner { padding-block-start: 2.4vh; }
}


/* ==========================================================================
   § Phone in LANDSCAPE — the rotated case
   A rotated phone is ~844px WIDE but only ~390px TALL, so it satisfies the
   `min-width: 768px` desktop block while having almost no height. Every size in
   that block is derived from vw, and rotating is exactly what makes vw large —
   which is why an earlier pass trimmed the header chrome here. It was trimmed too
   far: measured on a 844x390 phone the controls came out 26px tall with 8.6px
   labels. That is under the 44px touch minimum and below readable text.

   Fixed WITHOUT growing the visible mark: the glyph and label keep their small
   size, and the PADDING is restored so the tap area reaches 44px. The target is
   invisible; only the finger notices it.
   ========================================================================== */
@media (orientation: landscape) and (max-height: 520px) {
  .openTrigger,
  .themeToggle {
    min-height: 44px;              /* the finger, not the glyph */
    min-width: 44px;
    padding: 6px 8px;
    display: inline-grid;
    align-content: center;
    justify-items: center;
  }
  /* 8.6px is not readable at arm's length; 10px is the floor and it still sits
     comfortably under the wordmark on a short viewport. */
  .openTrigger_text,
  .themeToggle_label { font-size: 10px; letter-spacing: .08em; }
  .header_controls { gap: .2em; }  /* two 44px targets need the rhythm tightened */
}

/* ── Featured artist rail — the "there is more" affordance ──────────────────
   The rail is 14 cards wide inside a window that shows barely one on a phone. It
   has always been swipeable, but with a hard edge and no control it looked like
   the end of the list. The button steps it one card at a time (wrapping home at
   the end so it never dead-ends) and the fade shows the row continues.
   Positioned against the rail's right edge and sized to the 44px touch minimum. */
.artistsFeatured_wrap { position: relative; }
.artistsFeatured_wrap::after {          /* the row continues — a soft edge, not a rule */
  content: ""; position: absolute; inset-block: 0; inset-inline-end: 0; width: 56px;
  pointer-events: none;
  background: linear-gradient(to right, transparent, var(--color-black));
  opacity: .9;
}
.railNext {
  position: absolute; inset-inline-end: 6px; top: 50%; transform: translateY(-50%);
  z-index: 2; width: 44px; height: 44px; display: grid; place-items: center;
  border: 1px solid rgba(var(--fg-rgb), .22); border-radius: 50%;
  background: var(--color-black); color: var(--color-white);
  font-size: 26px; line-height: 1; cursor: pointer; padding: 0;
  transition: background-color .3s, border-color .3s, opacity .3s;
}
.railNext:hover { border-color: rgba(var(--fg-rgb), .5); }
.railNext[hidden] { display: none; }
.railNext.-end span { transform: rotate(180deg); }   /* wraps back to the start */
.railNext.-end { opacity: .75; }
/* Touch: the fade would sit over the card being swiped, so keep it slimmer. */
@media (hover: none) { .artistsFeatured_wrap::after { width: 34px; } }

/* ── DAY — the painting returns to the top of the page ──────────────────────
   Night shows a work behind the page title; Day hid it, because the ambient plate
   is switched off there and the field forced to flat white to kill the artworks'
   baked-in margins. Both of those must stay — but only where the WORKS are.

   So the plate comes back for the hero band alone: `-heroZone` is set while the
   page is still in its opening screen and dropped as you reach the grid, so the
   painting lives behind the title and the search, and the field under the first
   row of thumbnails is pure white exactly as it is now. It fades rather than
   switches, so arriving at the grid is a dissolve, not a cut. */
html[data-theme="day"] .ambientBg .mesh_fallback {
  display: block;                     /* Day switched this off wholesale; scoped now */
  opacity: 0;
  transition: opacity .75s cubic-bezier(.25, 1, .5, 1);

  /* COVER — and the reason is worth recording, because `contain` looks like the
     more faithful match to the shader and is not.

     Neither value distorts: contain letterboxes, cover crops, both keep the work's
     proportions. The shader can use contain because it feathers the plate's alpha
     in TEXTURE space, so the work dissolves into the page at its own edges. A CSS
     mask can only feather the ELEMENT box; with contain the image sits as a narrow
     column inside that box, so the mask fades the empty margins while the artwork
     itself still ends on two hard vertical lines — the artificial frame we have
     removed everywhere else. Cover fills the box, so the mask feathers the work
     itself and there is no edge anywhere. */
  object-fit: cover;

  /* MULTIPLY is what keeps Day white. Against the white page, multiply can only
     ever DARKEN, never lighten: paper stays exactly #FFF and only the painting's
     deeper strokes tint it. Lifting brightness first pushes the work's midtones up
     near white, so what survives the blend is the drawing — not the photograph.
     The result reads as pigment IN the paper rather than a picture laid on top. */
  mix-blend-mode: multiply;
  filter: saturate(.6) brightness(1.15) contrast(.88);

  /* No edge anywhere. The work is strongest behind the title and has fallen to
     nothing well before the band ends, so there is never a seam to see — belt to
     the `-heroZone` braces, which stop it reaching the grid at all. */
  -webkit-mask-image: radial-gradient(118% 94% at 50% 26%,
    #000 0%, #000 34%, rgba(0,0,0,.5) 63%, transparent 88%);
  mask-image: radial-gradient(118% 94% at 50% 26%,
    #000 0%, #000 34%, rgba(0,0,0,.5) 63%, transparent 88%);
}
html[data-theme="day"].-heroZone .ambientBg .mesh_fallback { opacity: .5; }

/* ── HOME's plate ───────────────────────────────────────────────────────────
   Home's plate needs no inverted rules any more: the gate in chrome-bottom.php now
   counts artwork bands on screen, and the slideshow hero is one of them, so the
   generic pair above already hides the backdrop behind the hero and reveals it over
   the browse panels below. All that is left here is home's white ground and keeping
   NIGHT's bare, fluid-lit field exactly as it was. */
html[data-theme="day"] .ambientBg.-wallOnly { background-color: #FFFFFF; }
html:not([data-theme="day"]) .ambientBg.-wallOnly .mesh_fallback { display: none; }
/* The WebGL path hides every .mesh_fallback, because normally a shader is drawing
   the same plate. Home is the exception: it has no ambient MESH by design (see
   chrome-top.php), so hiding its <img> left Day's backdrop drawn on machines
   WITHOUT WebGL and blank on the ones with it. Nothing else draws here, so there is
   no double-paint to worry about. */
html.-webgl[data-theme="day"] .ambientBg.-wallOnly .mesh_fallback { visibility: visible; }

/* The artists and about pages carry their OWN header hero rather than the shared
   fixed plate, so they need the identical treatment or the site changes character
   from page to page. White ground, artwork bleeding through it, same numbers.

   No `-heroZone` gate here: these elements are bounded to the header band and stop
   exactly where the content below starts, so they cannot reach a thumbnail — the
   gate exists only for the FIXED full-viewport plate, which can. */
html[data-theme="day"] .artistsHeader_bg,
html[data-theme="day"] .aboutHeader_bg { background-color: #FFFFFF; }
html[data-theme="day"] .artistsHeader_bg:after,
html[data-theme="day"] .aboutHeader_bg:after { background-color: transparent; }
html[data-theme="day"] .artistsHeader_bg .mesh_fallback,
html[data-theme="day"] .aboutHeader_bg .mesh_fallback {
  display: block;
  opacity: .5;
  object-fit: cover;     /* see the ambient plate above for why not contain */
  mix-blend-mode: multiply;
  filter: saturate(.6) brightness(1.15) contrast(.88);
  -webkit-mask-image: radial-gradient(118% 94% at 50% 26%,
    #000 0%, #000 34%, rgba(0,0,0,.5) 63%, transparent 88%);
  mask-image: radial-gradient(118% 94% at 50% 26%,
    #000 0%, #000 34%, rgba(0,0,0,.5) 63%, transparent 88%);
}



/* multiply needs white underneath to blend against — a transparent backdrop
   leaves the blend a no-op. This is the same white the page already is. */
html[data-theme="day"] .ambientBg { background-color: #FFFFFF; }

/* Reduced motion keeps the painting but not the dissolve. */
@media (prefers-reduced-motion: reduce) {
  html[data-theme="day"] .ambientBg .mesh_fallback { transition: none; }
}

/* ==========================================================================
   § Type on a SHORT viewport — the other half of the rotation problem
   A phone in landscape is ~844px WIDE and ~390px TALL, so it clears
   `min-width: 768px` and takes the DESKTOP type scale. Those sizes are
   clamp(floor, vw, ceiling) and on a short screen it is the FLOOR that hurts:
   .pfd_R50_H100's floor alone is 37.5px, i.e. a tenth of the whole viewport
   height for one line of a title, with a second line and the chrome still to fit.

   So here the scale is re-expressed against vh — the dimension that is actually
   scarce — with floors low enough to fit and ceilings that stop it growing back
   on a tall desktop window. Same ratios between the steps, so the hierarchy is
   identical; only the absolute size comes down.

   Ordered AFTER the SP block and the desktop block, so source order alone wins
   and nothing above needs !important.
   ========================================================================== */
@media (orientation: landscape) and (max-height: 520px) {
  .cz_R14_H100  { font-size: clamp(9px,   2.6vh, 12px); }
  .cz_R16_H100  { font-size: clamp(9.5px, 2.9vh, 13px); }
  .cz_R18_H100  { font-size: clamp(10px,  3.2vh, 14px); }
  .pfd_R20_H100 { font-size: clamp(12px,  3.6vh, 16px); }
  .pfd_R30_H100 { font-size: clamp(15px,  5vh,   23px); }
  .pfd_R40_H100 { font-size: clamp(18px,  6.4vh, 30px); }
  .pfd_R50_H100 { font-size: clamp(21px,  7.6vh, 36px); }
  .sm_R16_H200  { font-size: clamp(10px,  2.9vh, 13px); line-height: 1.7; }
  .sm_R16_H240  { font-size: clamp(10px,  2.9vh, 13px); line-height: 1.9; }

  /* The vertical CATALOGUE rail is positioned from vw too, which on a rotated
     phone pushes it off the bottom of a 390px viewport. */
  .label { top: clamp(64px, 22vh, 120px); }
}
