@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 {
  /* ── 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;
  --color-white-pure: #0a0801;   /* hero title -> darkest ink on paper */
  --color-white:      #26211a;   /* FOREGROUND: text, lines, dots -> warm ink */
  --color-black:      #F7F2EF;   /* BACKGROUND: page + panels -> cream paper */
  --color-black-pure: #F7F2EF;   /* transition veil -> cream */
  --color-yellow:     #9a7800;   /* Gold, deepened for contrast on paper */
  --fg-rgb: 38, 33, 26;          /* == --color-white (day) */
  --bg-rgb: 247, 242, 239;       /* == --color-black (day) == #F7F2EF */
  /* 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: #E5D6CD;         /* taupe raised panel */
  --accent-tan: #DCC3B4;        /* warm tan accent / hover */
  --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. */
html[data-theme="day"] .ambientBg .mesh_fallback,
html[data-theme="day"] .artistsHeader_bg .mesh_fallback,
html[data-theme="day"] .aboutHeader_bg .mesh_fallback { display: none; }

/* ── 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: #F5E8E1 !important; }
html[data-theme="day"] .homeHero_slide { background-color: #F5E8E1 !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). */
html[data-theme="day"] .pieceDialog_figure,
html[data-theme="day"] .pieceDialog_thumbImg { background-color: #F5E8E1; }
/* 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). */
html[data-theme="day"] .browsePanel {
  background-color: var(--sfc-panel);
  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 { background-color: var(--accent-tan); }
/* 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: "Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-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: 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: clip; }
.main { width: 100vw; }

/* ==========================================================================
   § Loader
   ========================================================================== */
.loader {
  position: fixed;
  inset: 0;
  z-index: var(--loader-index);
  width: 100vw;
  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. */
.header_controls { display: flex; flex-direction: row-reverse; align-items: center; gap: 1.6em; }
/* 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); }
.header.-min .header_logo,
.header.-min .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.-min .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: 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; }

/* 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: 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%);
  opacity: var(--sfc-guard-a, 0);
}

.homeHeader_mist,
.notFound_mist {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 45%;
  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: clip; padding-block: .1em; }
.button_text > * { grid-area: 1/1; }

/* ==========================================================================
   § Sticky scaffold (vertical label + content)
   ========================================================================== */
.sticky { display: grid; }
.sticky_sub { justify-self: start; }
.label {
  position: sticky;
  height: fit-content;
  inline-size: max-content;
  writing-mode: vertical-rl;
}
.paragraph_text { display: grid; }

/* ==========================================================================
   § Home — hero
   ========================================================================== */
.homeHeader { position: relative; height: 100svh; 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;               /* 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. */
}
.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: 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); }
.aboutOutline_row dt { opacity: .5; }

/* ==========================================================================
   § 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: 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: 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: 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
   portrait/landscape works is DELIBERATE design, not letterboxing.
   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)); }
.gallery_grid:not(.-static) .piece { margin-block-end: 0; } /* the grid gap owns the rhythm (features included) */
.gallery_grid:not(.-static) .piece .piece_media {
  aspect-ratio: 1 / 1;
  border: 1px solid rgba(var(--fg-rgb), .12); /* the mat's resting hairline */
}
.gallery_grid:not(.-static) .piece .piece_media.is-loaded { background-color: rgba(var(--fg-rgb), .05); } /* mat tint stays on */
/* 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: var(--sfc-mat); }
/* --------------------------------------------------------------------------
   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: Cinzel, 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: .7; }
.piece_maskLine { display: block; }
.piece_maskLabel { opacity: .55; 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: 100dvh;
  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: 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: 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: "Playfair Display", 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: Cinzel, Georgia, serif;
  font-size: 16px; line-height: 1.25; opacity: .82;
}
.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); }
.pieceDialog_row dt { opacity: .5; }
/* 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 { opacity: .5; 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: 100svh; overflow: clip; }
.artistsHeader_bg {
  position: absolute;
  inset: 0;
  z-index: -1;
  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: 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; /* house card ratio */
  overflow: clip;
}
.deck_layer {
  grid-area: 1/1;
  align-self: start;
  width: 100%;
  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%; /* full-bleed child keeps the page gutters */
  min-height: 100svh;
  text-align: start;
  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: 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: 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: Cinzel, 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: 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: Cinzel, 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: 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: Cinzel, 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: Cinzel, 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: Cinzel, 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: Cinzel, 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: "Playfair Display", 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: "Playfair Display", 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: "Playfair Display", 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: "Playfair Display", 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: "Shippori Mincho", 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: "Shippori Mincho", 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: 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 -- */
  .SP_cz_R11_H100 { font-family: Cinzel, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 2.7363184080vw; line-height: 1; }
  .SP_cz_R12_H100 { font-family: Cinzel, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 2.9850746269vw; line-height: 1; }
  .SP_cz_R24_H100 { font-family: Cinzel, serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 5.9701492537vw; line-height: 1; }
  .SP_pfd_M14_H100 { font-family: "Playfair Display", serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 3.4825870647vw; line-height: 1; }
  .SP_pfd_M20_H100 { font-family: "Playfair Display", serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 4.9751243781vw; line-height: 1; }
  .SP_pfd_R26_H100 { font-family: "Playfair Display", serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 6.4676616915vw; line-height: 1; }
  .SP_pfd_R30_H100 { font-family: "Playfair Display", serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 7.4626865672vw; line-height: 1; }
  .SP_ms_R12_H200 { font-family: "Shippori Mincho", serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 2.9850746269vw; line-height: 2; }
  .SP_sm_R12_H240 { font-family: "Shippori Mincho", serif; font-optical-sizing: auto; font-weight: 400; font-style: normal; font-size: 2.9850746269vw; 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; }
  .openTrigger, .themeToggle { gap: 5.7213930348vw; 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. */
  .header_controls { transition: opacity .45s cubic-bezier(.25, 1, .5, 1), transform .45s cubic-bezier(.25, 1, .5, 1); }
  .header.-min .header_controls { opacity: 0; transform: translateY(-.55rem); pointer-events: none; }

  /* -- 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: 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: 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.3383084577vw; } /* long records */
  /* 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(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, 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: 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;
}
