/* ===========================================================================
 * Website Builder — INTERIOR design system
 * ---------------------------------------------------------------------------
 * The ONE stylesheet every native interior page shares (image detail, gallery
 * listing, artist profile, journal post). Loaded by renderThemeInterior()
 * inside the theme shell, so [data-theme]/[data-framework] and the theme token
 * surface are in scope.
 *
 * RULES (keep interiors clean + consistent, no legacy bleedover):
 *   - Style ONLY through theme tokens: --accent, --bg, --bg-mid, --text,
 *     --muted, --rule, --font-display, --font-body, --btn-radius/-tracking/
 *     -transform/-weight. Never hardcode a colour or font here.
 *   - Every interior component composes from these `.ti-*` primitives — one
 *     source of truth, so the four page types can't visually drift.
 *   - No `--color-*` legacy tokens, no `gallery-detail-*`, no `<Nav>`.
 * ======================================================================== */

.ti {
  max-width: 1120px;
  margin: 0 auto;
  padding: 28px 20px 72px;
  color: var(--text);
  background: var(--bg);
  font-family: var(--font-body);
  line-height: 1.55;
}

/* --- Breadcrumb ---------------------------------------------------------- */
.ti-crumbs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--muted);
  margin: 0 0 20px;
}
.ti-crumbs a { color: var(--muted); text-decoration: none; }
.ti-crumbs a:hover { color: var(--accent-text); }
.ti-crumbs__sep { opacity: 0.5; }
.ti-crumbs__current { color: var(--text); }

/* --- Header block (eyebrow / title / byline) ---------------------------- */
/* 🚨 14px, down from 22px (operator, 2026-09-19: "tighten up this artist
   spot"). Everything in this header is one or two lines tall — eyebrow,
   name, face, availability, tagline, shop pill — so the gaps between them
   were doing more work than the content. */
.ti-header { margin: 0 0 14px; }
/* "Subscribe via RSS" under the journal heading. Muted like a crumb, accent on
 * hover like a crumb -- it is a utility, not a call to action. */
.ti-feed { margin: 10px 0 0; font-size: 13px; }
.ti-feed a { color: var(--muted); text-decoration: none; }
.ti-feed a:hover { color: var(--accent-text); }
.ti-eyebrow {
  font-size: 12px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--accent-text);
  margin: 0 0 10px;
}
.ti-title {
  font-family: var(--font-display);
  color: var(--text);
  font-size: clamp(28px, 5vw, 46px);
  line-height: 1.05;
  margin: 0 0 10px;
}
.ti-byline { font-size: 15px; color: var(--muted); margin: 0; }
.ti-byline a { color: var(--accent-text); text-decoration: none; }
.ti-byline a:hover { text-decoration: underline; text-underline-offset: 2px; }

/* --- Media -------------------------------------------------------------- */
.ti-figure {
  margin: 0 0 24px;
  border: 1px solid var(--rule);
  background: var(--bg-mid);
  border-radius: 4px;
  overflow: hidden;
}
.ti-figure img { display: block; width: 100%; height: auto; }
/* Video posts render a real <video> here (2026-08-03) — same box as the
   image so the figure doesn't jump between media types. */
/* One declaration of the video letterbox ground: a reel that does not fill its
   box shows this behind it, not the page. .ti-promo-video (the artist's promo
   reel in the aside) letterboxes for the same reason, so it shares the rule
   rather than restating #000 — check-css-colors counts every literal. */
.ti-figure video,
.ti-promo-video { display: block; width: 100%; height: auto; background: #000; }

/* 🚨 The height cap on gallery detail media. Without it media is sized purely
   by the column: at .ti's ~1118px a 9:16 reel (the Instagram default, and most
   of what tenants ingest) computes to ~1990px tall, so it overflowed the
   viewport and the visitor landed mid-video. Reported on bobparrtattoos
   2026-09-03; measured there at 1118x1988, cut to 1118x850.

   🚨 Cap HEIGHT, keep `width: 100%`. The tidier-looking alternative --
   `width: auto` + `max-width: 100%`, so the element hugs its ratio and never
   letterboxes -- is wrong here, and measurably: on prod 28,965 of the 29,881
   published images with a known width (96.9%) are NARROWER than this column,
   so it would stop upscaling them and shrink nearly every gallery image on the
   network. CF's `full` variant is fit=scale-down and cannot supply the missing
   pixels either. Keeping width definite also keeps the box reservable -- the
   width/height attrs ThemeImageDetail emits "for CLS" only reserve space while
   one dimension is definite -- so it would have traded overflow for a shift.

   The letterbox is therefore deliberate, and is already this file's
   convention: `.ti-figure video` declares `background: #000` as exactly "the
   video letterbox ground".

   🚨 85vh, not the legacy path's 75vh (app/gallery/image-detail.tsx): the same
   cap is a harder constraint on a wider column, and that path's figure is
   `max-width: 960px` against this one's ~1118px. At 1118 an ordinary 3:2
   landscape is 745px tall, which 75vh clamps on a 1440x900 laptop (675) and
   85vh does not (765) -- while the 9:16 reel is still cut to 765. On a
   viewport under ~800px tall a tall-ish landscape still picks up a small
   letterbox; that is the cap working, since it is already near full-screen.

   Direct children only, .ti-image-detail only: `>` skips CarouselViewer's
   `.carousel-media` (own cap) and the <img> inside <video>; the prefix keeps
   `.ti-portrait` (aspect-ratio:1/1 + cover) out of scope.
   769px, not 768: the phone reorder block below is `max-width: 768px`, and
   768 would apply both at exactly 768. */
@media (min-width: 769px) {
  .ti-image-detail .ti-figure > img,
  .ti-image-detail .ti-figure > video {
    max-height: 85vh;
    object-fit: contain;
  }
}
.ti-figcaption { padding: 12px 16px; font-size: 13px; color: var(--muted); }

/* --- Prose / article body ----------------------------------------------- */
/* The shared long-form primitive: bios, empty-states, AND journal post
 * bodies (dangerouslySetInnerHTML) all compose from this, so editorial
 * content can't visually drift from the rest of the interior system. */
.ti-prose { max-width: 68ch; color: var(--text); line-height: 1.75; }
.ti-prose p { margin: 0 0 1.15em; color: var(--muted); }
.ti-prose a { color: var(--accent-text); }
.ti-prose strong { color: var(--text); }
.ti-prose h2,
.ti-prose h3 {
  font-family: var(--font-display);
  color: var(--text);
  line-height: 1.2;
  margin: 1.6em 0 0.5em;
}
.ti-prose h2 { font-size: 1.5rem; }
.ti-prose h3 { font-size: 1.2rem; }
.ti-prose img {
  max-width: 100%;
  height: auto;
  border-radius: 4px;
  margin: 1.4em 0;
  display: block;
}
.ti-prose blockquote {
  border-left: 3px solid var(--accent);
  margin: 1.4em 0;
  padding: 0.4em 0 0.4em 1.2em;
  color: var(--muted);
  font-style: italic;
}
.ti-prose ul,
.ti-prose ol { margin: 0 0 1.15em; padding-left: 1.4em; color: var(--muted); }
.ti-prose li { margin: 0 0 0.4em; }
/* The MARKERS. Tailwind preflight ships `ol, ul, menu { list-style: none }`,
 * so the padding-left above was indenting a bullet that never rendered — an
 * authored list came out as unmarked lines on every themed tenant. Measured
 * 2026-09-01 alongside the same defect on the non-themed `.blog-body` path.
 * Nesting reads by marker shape as well as indent, because Quill emits a
 * nested level as `li.ql-indent-N` inside one flat list at least as often as
 * it emits a real child list. */
.ti-prose ul { list-style-type: disc; }
.ti-prose ol { list-style-type: decimal; }
.ti-prose ul ul { list-style-type: circle; }
.ti-prose ul ul ul { list-style-type: square; }
.ti-prose ol ol { list-style-type: lower-alpha; }
.ti-prose ol ol ol { list-style-type: lower-roman; }
.ti-prose li > ul,
.ti-prose li > ol { margin-top: 0.35em; margin-bottom: 0; }
/* H4 was unsized, so preflight's `font-size: inherit` left it at body size --
 * a heading that reads as a mistake. Same reason the h2/h3 rules above exist. */
.ti-prose h4 {
  font-family: var(--font-display);
  color: var(--text);
  line-height: 1.2;
  margin: 1.6em 0 0.5em;
  font-size: 1.05rem;
  letter-spacing: 0.01em;
}
/* Quill's alignment + indent classes, which only ever had CSS inside
 * `.ql-editor`: the editor honoured them and the published page dropped them,
 * so centring or indenting looked like it worked and then did nothing live. */
.ti-prose .ql-align-center { text-align: center; }
.ti-prose .ql-align-right { text-align: right; }
.ti-prose .ql-align-justify { text-align: justify; }
/* A bullet indented with Quill's indent button stays in the same flat <ul>,
 * so the `ul ul` shapes above never see it. Stepped by hand to match.
 * NOT done for <ol> — the native counter keeps running, so a stepped ordered
 * marker publishes 1, b, c, 4. See app/globals.css for the full reasoning. */
.ti-prose ul > li.ql-indent-1 { list-style-type: circle; }
.ti-prose ul > li.ql-indent-2 { list-style-type: square; }
.ti-prose .ql-indent-1 { margin-left: 3em; }
.ti-prose .ql-indent-2 { margin-left: 6em; }
.ti-prose .ql-indent-3 { margin-left: 9em; }
.ti-prose .ql-indent-4 { margin-left: 12em; }
.ti-prose .ql-indent-5 { margin-left: 15em; }
.ti-prose .ql-indent-6 { margin-left: 18em; }
.ti-prose .ql-indent-7 { margin-left: 21em; }
.ti-prose .ql-indent-8 { margin-left: 24em; }
/* Empty paragraphs. Quill writes a blank line as `<p><br></p>`; the editor
 * charges 18px for it and this page charges ~69px, so an author chasing a
 * small breath between paragraphs ends up with a chasm. Base rule is
 * universally supported; the `:has()` rule hands a small beat back where it
 * is supported, and is kept separate so old browsers drop only that half.
 * See app/globals.css for the measurements. */
.ti-prose p > br:only-child { display: none; }
.ti-prose p:has(> br:only-child) { margin: 0; height: 0.55em; }
/* Figures from the editor's image blot (G-3). Same data attributes as the
 * non-themed surfaces in app/globals.css; theme tokens for the caption. */
.ti-prose figure.ql-figure { margin: 1.5em auto; max-width: 100%; }
.ti-prose figure.ql-figure[data-width="medium"] { max-width: 66%; }
.ti-prose figure.ql-figure[data-width="small"] { max-width: 40%; }
.ti-prose figure.ql-figure[data-align="left"] { float: left; margin: 0.35em 1.5em 0.75em 0; }
.ti-prose figure.ql-figure[data-align="right"] { float: right; margin: 0.35em 0 0.75em 1.5em; }
.ti-prose figure.ql-figure img { display: block; width: 100%; height: auto; margin: 0; border-radius: 4px; }
.ti-prose figure.ql-figure figcaption { font-size: 13px; line-height: 1.5; text-align: center; color: var(--muted); padding: 8px 4px 0; }
.ti-prose figure.ql-figure figcaption:empty { display: none; }
/* Contain the float rather than clearing the last child -- clearing pushed a
 * trailing paragraph below the image so it never wrapped, and still let a
 * trailing figure escape the container. See app/globals.css. */
.ti-prose { display: flow-root; }
/* Safari 12 (the kiosk-iPad floor pinned in package.json) has no flow-root. */
.ti-prose::after { content: ''; display: block; clear: both; }
/* flow-root stops parent/child margin collapsing, so the first heading and the
 * last paragraph gain their own margin against the container edge. The globals
 * surfaces already carry these resets; .ti-prose did not. */
.ti-prose > :first-child { margin-top: 0; }
.ti-prose > :last-child { margin-bottom: 0; }
@media (max-width: 640px) {
  .ti-prose figure.ql-figure { float: none !important; max-width: 100% !important; margin: 1.5em auto !important; }
}
.ti-prose pre {
  margin: 0 0 1.15em;
  padding: 0.9em 1.1em;
  border-radius: 4px;
  /* The theme's own secondary surface, not a hardcoded tint: this stylesheet
   * serves light themes and dark ones, and either fixed grey is wrong on one
   * of them. `--rule` draws the edge so the block still reads on a theme whose
   * --bg-mid equals its --bg. */
  background: var(--bg-mid, transparent);
  border: 1px solid var(--rule, transparent);
  color: var(--muted);
  font-size: 0.9em;
  line-height: 1.5;
  white-space: pre-wrap;
  overflow-x: auto;
}
.ti-prose hr {
  margin: 2em 0;
  border: 0;
  border-top: 1px solid var(--rule, currentColor);
  opacity: 0.5;
}

/* --- Meta chips (styles, date, etc.) ------------------------------------ */
.ti-meta { display: flex; flex-wrap: wrap; gap: 8px; margin: 20px 0; }
.ti-chip {
  font-size: 12px;
  letter-spacing: 0.04em;
  padding: 4px 10px;
  border: 1px solid var(--rule);
  /* A knob, not a value: this file is UNLAYERED, so it beats every
     `@layer theme` bundle at any specificity, and a theme that wants square
     chips (art-immortal: "no radius anywhere") can only get them by setting
     the variable. Same mechanism as the walk-in board's --board-* knobs. */
  border-radius: var(--ti-chip-radius, 999px);
  color: var(--muted);
  text-decoration: none;
}
.ti-chip:hover { color: var(--accent-text); border-color: var(--accent); }

/* 🚨 The image detail's title and byline share ONE row (operator, 2026-09-18:
   "the text info can probably fit in one row"). A piece's title is a couple of
   words ("Hand", "Angel bites") and the byline is "by {artist} · {shop}" —
   stacked, they spent two lines and a heading's bottom margin to say less
   than a sentence, directly above an image that is the actual content. Scoped
   to .ti-image-detail: the ARTIST page's header is a name, a portrait, chips
   and a stat row, and has no business collapsing to a row.
   Baseline alignment, and it wraps rather than shrinking the title. */
.ti-image-detail > .ti-header {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 14px;
}
.ti-image-detail > .ti-header .ti-title { margin-bottom: 0; }
.ti-image-detail > .ti-header .ti-byline { margin: 0; }

/* --- Pager (prev / next) ------------------------------------------------ */
.ti-pager {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 12px;
  margin: 24px 0;
}
.ti-pager__prev { justify-self: start; }
.ti-pager__next { justify-self: end; }
.ti-pager__mid { justify-self: center; }
/* 🚨 A CONTROL, not a text link (operator, 2026-09-18: "make those forward
   and back look best in class"). These are the primary way through a gallery
   and they were two bare words floating at the edges of an empty row — no
   edge, no hit area you could see, nothing to say they were the same pair of
   things. Bordered, ink-filled on hover, and square or round by the theme's
   own --ti-chip-radius so a theme that has banned radius keeps its corners.
   Padding still clears the 44px Apple HIG floor (Material asks 48dp). */
.ti-pglink {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  color: var(--text);
  text-decoration: none;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  min-height: 44px;
  padding: 10px 18px;
  border: 1px solid var(--rule);
  border-radius: var(--ti-chip-radius, 4px);
  background: transparent;
  /* Under the 150ms feedback budget the themes hold themselves to. */
  transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.ti-pglink:hover {
  background: var(--text);
  color: var(--bg);
  border-color: var(--text);
}
.ti-pglink:focus-visible {
  outline: 2px solid var(--accent-text);
  outline-offset: 2px;
}
/* 🚨 The dead end keeps its BORDER and loses its fill, rather than fading the
   whole control: at the first and last piece one of the pair is unusable, and
   a control that has vanished to 35% reads as a rendering fault rather than
   as the end of the gallery. Muted type on the page's own ground says "there
   is nothing this way" while the row still looks like a row. */
.ti-pglink[aria-disabled='true'] {
  color: var(--muted);
  border-color: var(--rule);
  opacity: 0.55;
  pointer-events: none;
}

/* --- Numbered pagination (gallery listing) -------------------------------
   GalleryPagination variant="ti": the windowed 1 … n-1 [n] n+1 … N bar the
   network gallery has always had, restyled through theme tokens for the
   Website Builder interiors. Active page inverts onto the accent — same
   on-accent ink rule as the section system's --accent-contrast usage.
   44px minimum tap targets. */
.ti-pagenav {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  margin: 28px 0 0;
  font-family: var(--font-body);
  font-size: 14px;
}
.ti-pagenum {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  padding: 8px 10px;
  border: 1px solid var(--rule);
  border-radius: var(--btn-radius, 6px);
  color: var(--text);
  text-decoration: none;
  transition: color 0.15s ease, border-color 0.15s ease;
}
.ti-pagenum:hover { color: var(--accent-text); border-color: var(--accent); }
.ti-pagenum--active {
  background: var(--accent);
  color: var(--accent-contrast, var(--bg));
  border-color: var(--accent);
  font-weight: 600;
}
.ti-pagenum--active:hover { color: var(--accent-contrast, var(--bg)); }
.ti-pagenav__gap { color: var(--muted); padding: 0 2px; }

/* --- Table of contents --------------------------------------------------
   Rendered by components/blog/BlogToc.tsx with variant="theme". */
.ti-toc {
  margin: 0 0 28px;
  padding: 14px 18px;
  border-left: 2px solid var(--accent);
  background: var(--bg-mid, rgba(127, 127, 127, 0.06));
  border-radius: 0 6px 6px 0;
}
.ti-toc__h {
  margin: 0 0 8px;
  font-family: var(--font-body);
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}
.ti-toc__list { margin: 0; padding: 0; list-style: none; }
.ti-toc__list li { margin: 0 0 5px; line-height: 1.4; }
.ti-toc__list li:last-child { margin-bottom: 0; }
.ti-toc__list a { font-family: var(--font-body); font-size: 14px; color: var(--text); text-decoration: none; }
.ti-toc__list a:hover { color: var(--accent-text); text-decoration: underline; }
.ti-toc__sub { padding-left: 16px; }
.ti-toc__sub a { font-size: 13px; color: var(--muted); }
/* Fragment jumps land under the theme's fixed nav rather than behind it. */
.ti-prose h2, .ti-prose h3 { scroll-margin-top: 90px; }

/* --- Related posts ------------------------------------------------------
   Rendered by components/blog/BlogRelated.tsx with variant="theme". */
.ti-related { margin: 32px 0 0; }
.ti-related__h {
  font-family: var(--font-display);
  font-size: 18px;
  color: var(--text);
  margin: 0 0 14px;
}
.ti-related__grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 14px;
  grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
}
.ti-related__card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  height: 100%;
  padding: 10px;
  border: 1px solid var(--rule);
  border-radius: var(--btn-radius, 8px);
  text-decoration: none;
  transition: border-color 0.15s ease, transform 0.15s ease;
}
.ti-related__card:hover { border-color: var(--accent); transform: translateY(-2px); }
.ti-related__thumb {
  width: 100%;
  aspect-ratio: 16 / 10;
  object-fit: cover;
  border-radius: 5px;
  display: block;
}
.ti-related__t {
  font-family: var(--font-display);
  font-size: 15px;
  line-height: 1.35;
  color: var(--text);
}
.ti-related__card:hover .ti-related__t { color: var(--accent-text); }
.ti-related__d { font-size: 12px; color: var(--muted); }

/* --- Post nav (prev / next blog post) -----------------------------------
   Richer than .ti-pager on purpose: a blog neighbor needs its TITLE and DATE
   on the card, the way the /support Knowledge Center does it. Rendered by
   components/blog/BlogPostNav.tsx with variant="theme". */
.ti-postnav {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
  margin: 32px 0 24px;
}
.ti-postnav__link {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-height: 44px;
  padding: 14px 16px;
  border: 1px solid var(--rule);
  border-radius: var(--btn-radius, 8px);
  text-decoration: none;
  transition: border-color 0.15s ease, transform 0.15s ease;
}
.ti-postnav__link:hover { border-color: var(--accent); transform: translateY(-2px); }
.ti-postnav__next { text-align: right; align-items: flex-end; }
.ti-postnav__label {
  font-family: var(--font-body);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent-text);
}
.ti-postnav__title {
  font-family: var(--font-display);
  font-size: 16px;
  line-height: 1.3;
  color: var(--text);
}
.ti-postnav__link:hover .ti-postnav__title { color: var(--accent-text); }
.ti-postnav__date { font-size: 12px; color: var(--muted); }
@media (max-width: 560px) {
  .ti-postnav { grid-template-columns: 1fr; }
  .ti-postnav__next { text-align: left; align-items: flex-start; }
}

/* --- Buttons (share the theme's button shape tokens) -------------------- */
.ti-btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 11px 22px;
  font-family: var(--font-body);
  font-weight: var(--btn-weight, 600);
  letter-spacing: var(--btn-tracking, 0.04em);
  text-transform: var(--btn-transform, none);
  border-radius: var(--btn-radius, 4px);
  text-decoration: none;
  cursor: pointer;
  border: 1px solid transparent;
}
.ti-btn--primary { background: var(--accent); color: var(--bg); }
.ti-btn--primary:hover { filter: brightness(1.08); }
.ti-btn--ghost { background: transparent; color: var(--text); border-color: var(--rule); }
.ti-btn--ghost:hover { border-color: var(--accent); color: var(--accent-text); }

/* --- Grid (gallery listing / related) ----------------------------------- */
.ti-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 12px;
}
/* 🚨 `display: block` below overrides the `hidden` attribute, so the chip
   filter (GalleryChipsClient sets tile.hidden) could not hide anything on the
   artist page. Found in review 2026-08-31 alongside the selector bug. */
.ti-grid__tile[hidden] { display: none; }
.ti-grid__tile {
  display: block;
  /* positioned: MediaBadge (video ▶ / carousel count) overlays the tile */
  position: relative;
  border: 1px solid var(--rule);
  background: var(--bg-mid);
  border-radius: 4px;
  overflow: hidden;
  aspect-ratio: 1 / 1;
}
.ti-grid__tile img {
  width: 100%; height: 100%; object-fit: cover; display: block;
  transition: filter 0.35s ease, transform 0.35s ease;
}

/* Rollover: the photo falls back and the piece's own title comes forward in
   the theme's display face (operator 2026-08-31). Gated three ways —
   `--titled` so a tile with no title never blurs to reveal nothing,
   `hover: hover` so a phone tap does not leave a tile stuck blurred, and
   reduced-motion so the reveal is instant rather than animated. */
@media (hover: hover) {
  .ti-grid__tile--titled:hover img { filter: blur(6px) brightness(0.5); transform: scale(1.05); }
  .ti-grid__tile--titled:hover .ti-grid__cap { opacity: 1; }
}
.ti-grid__tile--titled:focus-visible img { filter: blur(6px) brightness(0.5); transform: scale(1.05); }
.ti-grid__tile--titled:focus-visible .ti-grid__cap { opacity: 1; }
.ti-grid__cap {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
  opacity: 0;
  transition: opacity 0.28s ease;
  pointer-events: none;
}
.ti-grid__cap-text {
  font-family: var(--font-display);
  font-size: clamp(14px, 1.5vw, 19px);
  line-height: 1.25;
  letter-spacing: 0.04em;
  /* 🚨 WHITE, not var(--text), and for the same reason the reel's scrims are
     theme-agnostic: this type sits on a photograph the rollover has
     deliberately darkened (`brightness(0.5)`), so the ground is dark whatever
     the theme's ground is. On a light theme --text is the page's near-black
     and the title rendered dark-on-dark — measured on Art Immortal 2026-09-18
     from an operator screenshot, "HAND" in #0B0B0B over a halved photo. A
     knob, so a theme that darkens differently can say so; the platform
     default follows the filter, not the palette. */
  color: var(--ti-grid-cap-color, #ffffff);
  text-align: center;
  text-wrap: balance;
  /* The darkening sits behind the words, but a light image can still ride up
     under them — a soft shadow keeps the title legible on any photo. */
  text-shadow: 0 2px 14px rgba(0, 0, 0, 0.75);
}
@media (prefers-reduced-motion: reduce) {
  .ti-grid__tile img,
  .ti-grid__cap { transition: none; }
  .ti-grid__tile--titled:hover img { transform: none; }
}

/* --- Expanded gallery treatments (SECTION-VARIANT-CANON V-3 / C-4) -------
   Map + rationale: lib/themes/interior/expanded.ts. Scoped to artist-work so
   the shared .ti-grid primitive on /gallery is not repainted. */
[data-section="artist-work"][data-treatment="hover"] .ti-grid__tile img {
  transition: transform 0.35s ease;
}
[data-section="artist-work"][data-treatment="hover"] .ti-grid__tile:hover img,
[data-section="artist-work"][data-treatment="hover"] .ti-grid__tile:focus-visible img {
  transform: scale(1.06);
}
/* Applied only inside no-preference: the tiles must stay visible when motion
   is reduced, not be animated and then cancelled. */
@media (prefers-reduced-motion: no-preference) {
  [data-section="artist-work"][data-treatment="stagger"] .ti-grid__tile {
    animation: ti-stagger-in 0.5s ease both;
  }
  @keyframes ti-stagger-in {
    from { opacity: 0; transform: translateY(10px); }
    to   { opacity: 1; transform: none; }
  }
  /* Stepped in bands, not per tile: a 45ms-per-tile cascade over 12 tiles is
     not what the eye reads, and 12 rules ship on every interior page. */
  [data-section="artist-work"][data-treatment="stagger"] .ti-grid__tile:nth-child(n + 4) { animation-delay: 90ms; }
  [data-section="artist-work"][data-treatment="stagger"] .ti-grid__tile:nth-child(n + 8) { animation-delay: 180ms; }
  [data-section="artist-work"][data-treatment="stagger"] .ti-grid__tile:nth-child(n + 12) { animation-delay: 270ms; }
}
[data-section="artist-work"][data-treatment="bento"] .ti-grid { grid-auto-flow: dense; }
@media (min-width: 620px) {
  [data-section="artist-work"][data-treatment="bento"] .ti-grid__tile:nth-child(7n + 1) {
    grid-column: span 2;
    grid-row: span 2;
    aspect-ratio: auto;
  }
}
[data-section="artist-work"][data-treatment="portrait"] .ti-grid {
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
[data-section="artist-work"][data-treatment="portrait"] .ti-grid__tile { aspect-ratio: 3 / 4; }
[data-section="artist-work"][data-treatment="editorial"] .ti-grid {
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 20px;
}
[data-section="artist-work"][data-treatment="editorial"] .ti-grid__tile {
  aspect-ratio: 4 / 5;
  border-radius: 2px;
}

/* Artist intro treatments (roster family). Map: lib/themes/interior/expanded.ts */
[data-section="artist-intro"][data-treatment="glow"] .ti-portrait img {
  transition: box-shadow 0.3s ease;
}
[data-section="artist-intro"][data-treatment="glow"] .ti-portrait:hover img {
  box-shadow: 0 0 0 3px var(--accent);
}
/* Beats .ti-portrait--round, which the stacked intro hardcodes — the square
   treatment exists precisely to undo it. */
[data-section="artist-intro"][data-treatment="square"] .ti-portrait--round,
[data-section="artist-intro"][data-treatment="square"] .ti-portrait--round img {
  border-radius: 4px;
}

/* Ticket treatments (pricing family). One structure, so these are looks. */
[data-section="event-tickets"][data-treatment="cards"] .ti-tickets,
[data-section="event-tickets"][data-treatment="lift"] .ti-tickets {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 14px;
}
[data-section="event-tickets"][data-treatment="lift"] .ti-ticket {
  transition: transform 0.2s ease;
}
[data-section="event-tickets"][data-treatment="lift"] .ti-ticket:hover {
  transform: translateY(-3px);
}
[data-section="event-tickets"][data-treatment="table"] .ti-tickets {
  display: table;
  width: 100%;
}
[data-section="event-tickets"][data-treatment="table"] .ti-ticket {
  display: table-row;
}

/* Lineup treatments (roster family, teaser density). Map: expanded.ts */
[data-section="event-lineup"][data-treatment="round"] .ti-lineup__photo {
  border-radius: 999px;
}
[data-section="event-lineup"][data-treatment="square"] .ti-lineup__photo {
  border-radius: 2px;
}
[data-section="event-lineup"][data-treatment="glow"] .ti-lineup__photo {
  transition: box-shadow 0.3s ease;
}
[data-section="event-lineup"][data-treatment="glow"] .ti-lineup__item:hover .ti-lineup__photo {
  box-shadow: 0 0 0 3px var(--accent);
}

/* --- Journal cards (blog index) ------------------------------------------ */
.ti-cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 14px;
}
.ti-card {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--rule);
  background: var(--bg-mid);
  border-radius: 4px;
  overflow: hidden;
  text-decoration: none;
}
.ti-card__media { aspect-ratio: 16 / 9; overflow: hidden; background: var(--bg-mid); }
.ti-card__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 0.35s ease;
}
.ti-card:hover .ti-card__media img { transform: scale(1.04); }
.ti-card__body { padding: 18px; display: flex; flex-direction: column; flex: 1; }
.ti-card__date {
  font-size: 12px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0 0 8px;
}
.ti-card__title {
  font-family: var(--font-display);
  font-size: 19px;
  line-height: 1.25;
  color: var(--text);
  margin: 0 0 8px;
}
.ti-card__excerpt { font-size: 14px; line-height: 1.6; color: var(--muted); margin: 0; flex: 1; }
.ti-card__more {
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent-text);
  margin-top: 14px;
}

/* --- Section head (title + trailing link) ------------------------------- */
.ti-secthead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  margin: 44px 0 16px;
}
.ti-secthead h2 {
  font-family: var(--font-display);
  color: var(--text);
  font-size: clamp(20px, 3vw, 28px);
  line-height: 1.1;
  margin: 0;
}
.ti-secthead__link {
  font-size: 13px;
  color: var(--accent-text);
  text-decoration: none;
  white-space: nowrap;
}
.ti-secthead__link:hover { text-decoration: underline; text-underline-offset: 2px; }

/* --- Artist portrait (aside of .ti-detail) ------------------------------ */
/* The artist's face, under their name.
   🚨 128px since 2026-09-18 (operator: "make artist photo bigger"), up from
   92px. It stays under the name rather than beside it — the right column is
   the reel, and a portrait floating next to the headline pushed the reel
   below the fold entirely (operator, 2026-09-07). Bigger, same place.
   🚨 The 128px fallback is written THREE times — here twice and again in the
   square-avatar rescue block near the end of this file, which has to restate
   it at higher specificity because `[data-section] img` reverts the height.
   Change one, change all three; they are listed in that block's header. */
.ti-header__thumb {
  display: block;
  width: var(--ti-header-thumb, 128px);
  height: var(--ti-header-thumb, 128px);
  object-fit: cover;
  border-radius: var(--ti-header-thumb-radius, 50%);
  margin: 10px 0 2px;
}
.ti-portrait { margin: 0; }
.ti-portrait img { aspect-ratio: 1 / 1; object-fit: cover; }
/* 🚨 On a phone the portrait comes FIRST. The DOM order is text-then-photo
   because the desktop grid wants the bio in the left column and the portrait
   in the right; when that grid collapses to one column the portrait fell
   BELOW the whole bio, so an artist page opened on a face-less wall of text
   and you had to scroll past it to find out who you were reading about.
   Reported by a client: "Artist bio pages on mobile should have artist pic
   above the text, currently under" (Sean, Darkside, 2026-08-14).

   Fixed with `order`, not by moving the markup: the DOM order is correct for
   desktop and for a screen reader (name and bio before a decorative
   portrait), and reordering the source would have to be undone at 860px. */
@media (max-width: 859px) {
  .ti-detail { display: flex; flex-direction: column; }
  /* 🚨 BOTH selectors, and both are load-bearing. The portrait gained a
     wrapper (.ti-portrait-stack) in 2026-08-23 so a promo reel could sit
     under it — which moved .ti-portrait out of being a DIRECT child of
     .ti-detail and would have silently killed the rule above it, putting
     the photo back below the bio: exactly the regression Sean reported.
     The bare .ti-portrait selector stays for any variant that still places
     the figure directly in the grid. */
  .ti-detail > .ti-portrait,
  .ti-detail > .ti-portrait-stack { order: -1; margin-bottom: 20px; }
}

/* Portrait + promo reel share the aside column. */
.ti-portrait-stack { display: flex; flex-direction: column; gap: 12px; margin: 0; }
.ti-promo-video { border-radius: 8px; }

/* --- Layout helpers ----------------------------------------------------- */
.ti-actions { display: flex; flex-wrap: wrap; gap: 12px; margin: 8px 0 24px; }
@media (min-width: 860px) {
  .ti-detail { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 32px; align-items: start; }
  /* 🚨 ...but only when there IS a second column. ArtistIntroA emits its aside
     only for an artist with a promo_video_url, and its own comment says "this
     column simply does not render when there is no reel" — which was true of
     the MARKUP and never of this rule. The two tracks were declared
     unconditionally, so every artist without an uploaded reel reserved an
     empty one. Measured live at 1440 on an artist with one child: tracks
     computed `611.328px 436.656px`, leaving 437 x 613 px of guaranteed blank
     beside the name — 41.7% of the content column, for the full height of the
     intro, with the artist's work pushed below the fold. Reported by the
     operator as "a bunch of white space".

     `:only-child` rather than `:has()`: the `safari 12` pin for the kiosk
     iPads drops `:has()`, and it would drop it SILENTLY, restoring the void on
     exactly the devices nobody checks. The measure cap keeps a long bio from
     running the full 1080px once it is free to. */
  .ti-detail > *:only-child { grid-column: 1 / -1; max-width: 68ch; }
}

/* --- Image detail: parity blocks (2026-08-25) ----------------------------
   Publish date, description/caption prose, share + IG source, and the
   Explore keyword chips — the details the themed page used to drop vs the
   legacy gallery detail. All token-pure per the interior rules. */
.ti-detail-date {
  font-size: 12px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 12px 0 0;
}
.ti-detail-desc { margin: 18px 0 0; }
.ti-detail-desc .desc-ext-link {
  display: inline-block;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-decoration: none;
  padding: 0.55rem 1.25rem;
  border: 1px solid currentColor;
  border-radius: 2px;
  margin-right: 0.5rem;
}
.ti-share { margin: 20px 0 0; }
.ti-source { margin: 10px 0 0; font-size: 12px; }
.ti-source a { color: var(--muted); text-decoration: none; }
.ti-source a:hover { color: var(--accent-text); }
.ti-explore { margin: 32px 0 0; }
.ti-explore__h {
  font-family: var(--font-display);
  color: var(--text);
  font-size: 15px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  margin: 0 0 12px;
}
.ti-explore__groups {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 18px;
}
.ti-explore__group {
  display: inline-flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
}
.ti-explore__label {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

/* --- FAQ (image-detail fact-derived Q&A) --------------------------------- */
.ti-faq { margin: 40px 0 0; padding-top: 24px; border-top: 1px solid var(--rule); }
.ti-faq__item + .ti-faq__item { margin-top: 18px; }
.ti-faq__question {
  font-family: var(--font-display);
  color: var(--text);
  font-size: 17px;
  line-height: 1.3;
  margin: 0 0 6px;
}
.ti-faq__answer { font-size: 14px; line-height: 1.6; color: var(--muted); margin: 0; }

/* --- Image detail: "Made at" (F-3) --------------------------------------
   The cross-shop door. An image detail is an ENTRY point far more often than
   a step in a browse — it is the page Google Images and Pinterest hand
   people — so once the byline says which shop a piece came from, the shop
   has to be openable.

   Quiet on purpose: a rule and a heading, not a card with a fill. The photo
   is the subject of this page and a boxed CTA under it competes with the
   pager for the same attention. Same weight as .ti-faq above, which is the
   block it sits beside.

   Only rendered on a multi-shop studio with the artist actually assigned, so
   these rules are dead on the ~99% of tenants with one address. */
.ti-madeat { margin: 40px 0 0; padding-top: 24px; border-top: 1px solid var(--rule); }
.ti-madeat__h {
  font-family: var(--font-display);
  color: var(--text);
  font-size: 17px;
  line-height: 1.3;
  margin: 0 0 6px;
}
.ti-madeat__h a { color: var(--accent-text); text-decoration: none; }
.ti-madeat__h a:hover { text-decoration: underline; }
.ti-madeat__p { font-size: 14px; line-height: 1.6; color: var(--muted); margin: 0; }
.ti-madeat__others { font-size: 14px; line-height: 1.6; color: var(--muted); margin: 10px 0 0; }
.ti-madeat__label { color: var(--muted); }
.ti-madeat__others a { color: var(--accent-text); text-decoration: none; }
.ti-madeat__others a:hover { text-decoration: underline; }
/* The byline's shop name is a link now too (it used to be plain text). */
.ti-byline__place a { color: inherit; text-decoration: underline; }

/* --- Image detail: phone layout ----------------------------------------
   On a phone the photo IS the page, so it comes directly under the
   breadcrumb; title, byline and CTA follow it. Done with flex `order`, not by
   reordering the DOM, so the H1 stays early in the document for SEO and
   screen-reader order (operator 2026-07-26: "on mobile, h1 can be below
   fold"). Safe for tab order too — the figure contains no focusable elements,
   so the relative order of every link on the page is unchanged.

   Before this, ~59% of a 390x844 viewport was chrome before the image. */
@media (max-width: 768px) {
  .ti-image-detail {
    display: flex;
    flex-direction: column;
  }
  .ti-image-detail > .ti-crumbs { order: 0; }
  /* Same order as the breadcrumb, so source order decides and the top pager
     lands between it and the image — above the fold, where the ask was. */
  .ti-image-detail > .ti-pager-top { order: 0; }
  .ti-image-detail > [data-swipe-zone],
  .ti-image-detail > .ti-figure { order: 1; }
  .ti-image-detail > .ti-header { order: 2; margin-top: 4px; }
  .ti-image-detail > .ti-actions { order: 3; }
  .ti-image-detail > .ti-detail-date { order: 4; }
  .ti-image-detail > .ti-pager { order: 5; }
  .ti-image-detail > .ti-detail-desc { order: 6; }
  .ti-image-detail > .ti-share { order: 7; }
  .ti-image-detail > .ti-explore { order: 8; }
  .ti-image-detail > .ti-madeat { order: 9; }
  .ti-image-detail > .ti-faq { order: 10; }
}


/* ============================================================
   /artists — studio roster page (ThemeArtistRoster).
   Uses the same .ti-* tokens as the rest of the interior pages so it
   inherits the tenant's theme with no bespoke design.
   ============================================================ */
.ti-roster {
  list-style: none;
  margin: 0 0 32px;
  padding: 0;
  display: grid;
  /* auto-fit + minmax so a 3-person shop and a 15-person shop both look
     deliberate without a breakpoint per size. */
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 28px 24px;
}
/* --- Roster split by shop (multi-location studio) ------------------------
   The page's one h1 is "Our Artists"; each shop is an h2 and the artist names
   inside drop to h3, so the outline says what the page means. The card styles
   are untouched — only the wrapper is new.

   🚨 Knob, not a rule, for the divider: interior.css is UNLAYERED, so a theme
   rule written against .ti-roster-group would lose at any specificity. */
.ti-roster-group + .ti-roster-group {
  border-top: var(--ti-roster-group-rule, 1px) solid var(--rule);
  padding-top: 8px;
}
/* The first group sits under the page byline, which already has space; the
   shared .ti-secthead margin would double it. */
.ti-roster-group:first-of-type .ti-secthead { margin-top: 24px; }

.ti-roster__link {
  display: block;
  text-decoration: none;
  color: inherit;
  transition: transform 0.18s ease;
}
.ti-roster__link:hover { transform: translateY(-2px); }
.ti-roster__link--static { cursor: default; }
.ti-roster__photo {
  width: 100%;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  border-radius: 50%;
  background: var(--bg-mid, rgba(255,255,255,0.06));
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 14px;
}
.ti-roster__photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ti-roster__initials {
  font-family: var(--font-display, serif);
  font-size: 30px;
  color: var(--muted, #999);
}
.ti-roster__name {
  font-family: var(--font-display, serif);
  font-size: 20px;
  margin: 0 0 4px;
  text-align: center;
}
.ti-roster__roles,
.ti-roster__styles {
  margin: 0 0 4px;
  text-align: center;
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted, #999);
}
.ti-roster__roles { color: var(--accent-text, #c9a227); }
.ti-roster__bio {
  margin: 8px 0 0;
  text-align: center;
  font-size: 14px;
  line-height: 1.5;
  color: var(--muted, #999);
}
.ti-roster__foot {
  text-align: center;
  color: var(--muted, #999);
  font-size: 14px;
}
@media (prefers-reduced-motion: reduce) {
  .ti-roster__link { transition: none; }
  .ti-roster__link:hover { transform: none; }
}

/* --- Upcoming appearances (artist-events slot) --------------------------
   The `events` family at teaser density, scoped to one artist. Tokens only —
   no literal colours — so every theme dresses it the same way it dresses the
   homepage events widget. */
.ti-events {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
  background: var(--rule);
  border: 1px solid var(--rule);
  border-radius: 6px;
  overflow: hidden;
}
.ti-events__row { margin: 0; }
.ti-events__item {
  display: flex;
  align-items: baseline;
  gap: 14px;
  padding: 14px 16px;
  background: var(--bg-mid);
  color: var(--text);
  text-decoration: none;
  font-family: var(--font-body);
  transition: background 0.15s ease;
}
a.ti-events__item:hover { background: var(--bg); }
a.ti-events__item:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
.ti-events__name {
  font-weight: 600;
  font-size: 15px;
  color: var(--text);
}
.ti-events__meta {
  font-size: 13px;
  color: var(--muted);
}
/* Variant A stacks name over meta on a phone; the row is baseline-aligned on
   wider screens. */
@media (max-width: 560px) {
  .ti-events__item {
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
  }
}

/* Variant B — date-block rail. */
.ti-events--rail .ti-events__item { align-items: center; gap: 16px; }
.ti-events--rail .ti-events__date {
  flex: none;
  display: grid;
  place-items: center;
  width: 52px;
  padding: 6px 0;
  border: 1px solid var(--rule);
  border-radius: 4px;
  line-height: 1.05;
}
.ti-events__month {
  font-family: var(--font-body);
  font-size: 10px;
  letter-spacing: 0.1em;
  color: var(--accent-text);
  font-weight: 700;
}
.ti-events__day {
  font-family: var(--font-display);
  font-size: 20px;
  color: var(--text);
}
.ti-events__body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
@media (max-width: 560px) {
  .ti-events--rail .ti-events__item { flex-direction: row; align-items: center; }
}

/* --- Artist intro, variant B (centred stack) ----------------------------
   Variant A's two-column grid leaves an empty column when an artist has no
   photo; this variant is single-column by construction. */
.ti-detail--stacked {
  display: block;
  max-width: 720px;
  margin-inline: auto;
  text-align: center;
}
.ti-header--center,
.ti-meta--center,
.ti-actions--center { text-align: center; justify-content: center; }
.ti-meta--center,
.ti-actions--center { display: flex; flex-wrap: wrap; }
.ti-prose--center { text-align: left; }
.ti-portrait--round {
  width: 168px;
  margin: 0 auto 20px;
}
.ti-portrait--round img {
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 999px;
}

/* --- Event detail (event-detail slots) ----------------------------------
   Tokens only, so every theme dresses the event page the way it dresses its
   own interiors. */
.ti-note {
  font-family: var(--font-body);
  font-size: 13px;
  color: var(--muted);
  border-left: 2px solid var(--accent);
  padding-left: 12px;
  margin: 20px 0 0;
}

/* Event header, variant B — banner with the title over it. */
.ti-eventbanner {
  position: relative;
  border-radius: 8px;
  overflow: hidden;
  min-height: 260px;
  display: grid;
  place-items: center;
  text-align: center;
  background: var(--bg-mid);
}
.ti-eventbanner__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0.4;
}
.ti-eventbanner__body { position: relative; padding: 40px 24px; max-width: 720px; }
.ti-eventbanner--flat { min-height: 0; }

/* Lineup, variant A — artist cards. */
.ti-lineup {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 10px;
}
.ti-lineup__row { margin: 0; }
.ti-lineup__item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 12px;
  border: 1px solid var(--rule);
  border-radius: 6px;
  background: var(--bg-mid);
  color: var(--text);
  text-decoration: none;
  font-family: var(--font-body);
  height: 100%;
}
a.ti-lineup__item:hover { border-color: var(--accent); }
a.ti-lineup__item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ti-lineup__photo {
  flex: none;
  width: 46px;
  height: 46px;
  border-radius: 999px;
  object-fit: cover;
  background: var(--bg);
}
.ti-lineup__photo--empty { display: block; border: 1px solid var(--rule); }
.ti-lineup__body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.ti-lineup__name { font-weight: 600; font-size: 14px; }
.ti-lineup__meta { font-size: 12px; color: var(--muted); }
.ti-lineup__flag {
  margin-left: auto;
  flex: none;
  font-size: 9px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent-text);
  border: 1px solid var(--accent);
  border-radius: 3px;
  padding: 2px 5px;
}

/* Lineup, variant B — dense name list. */
.ti-namelist {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 6px 20px;
  font-family: var(--font-body);
}
.ti-namelist__item { display: flex; align-items: baseline; gap: 6px; font-size: 15px; }
.ti-namelist__item a { color: var(--text); text-decoration: none; }
.ti-namelist__item a:hover { color: var(--accent-text); text-decoration: underline; text-underline-offset: 2px; }
.ti-namelist__flag { color: var(--accent-text); font-size: 11px; }

/* Schedule. */
.ti-sched__day { margin-top: 24px; }
.ti-sched__dayhead {
  font-family: var(--font-display);
  font-size: 16px;
  color: var(--accent-text);
  margin: 0 0 10px;
}
.ti-sched { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
.ti-sched__row {
  display: flex;
  gap: 16px;
  padding: 12px 14px;
  background: var(--bg-mid);
  border: 1px solid var(--rule);
  border-radius: 6px;
  font-family: var(--font-body);
}
.ti-sched__when {
  flex: none;
  width: 132px;
  font-size: 13px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.ti-sched__body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.ti-sched__title { font-weight: 600; font-size: 15px; color: var(--text); }
.ti-sched__meta { font-size: 12px; color: var(--muted); }
.ti-sched__desc { font-size: 13px; color: var(--muted); line-height: 1.6; margin-top: 4px; }
@media (max-width: 640px) {
  .ti-sched__row { flex-direction: column; gap: 6px; }
  .ti-sched__when { width: auto; }
}

/* Tickets. */
.ti-tickets {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 12px;
}
.ti-ticket {
  border: 1px solid var(--rule);
  border-radius: 8px;
  padding: 16px;
  background: var(--bg-mid);
  display: flex;
  flex-direction: column;
  gap: 10px;
  font-family: var(--font-body);
}
.ti-ticket--gone { opacity: 0.6; }
.ti-ticket__head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.ti-ticket__name { font-weight: 600; font-size: 15px; color: var(--text); }
.ti-ticket__price { font-family: var(--font-display); font-size: 20px; color: var(--accent-text); }
.ti-ticket__was { font-size: 11px; color: var(--muted); text-decoration: line-through; }
.ti-ticket__includes { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.6; }
.ti-ticket__cta { align-self: flex-start; margin-top: auto; }
.ti-ticket__gone {
  align-self: flex-start;
  margin-top: auto;
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
  border: 1px solid var(--rule);
  border-radius: 3px;
  padding: 4px 8px;
}

/* --- Hero at SLIM density (interior page headers) -------------------------
 *
 * docs/SECTION-VARIANT-CANON.md V-2 + §4a. ThemeShell stamps
 * [data-hero-slim] on the wrapper of every themed interior page; the
 * treatment comes from the DECLARED ladder in lib/themes/hero-slim.ts, so a
 * tenant's interior header wears their own hero, not a platform default.
 *
 * 🚨 Every selector here is [data-hero-slim="x"] .tn-page-header (0,2,0) so
 * it beats PageHeader's own emitted .tn-page-header rules (0,1,0), which
 * render LATER in the document. Specificity is doing the work, not order —
 * see the docblock in components/layout/PageHeader.tsx.
 *
 * 🚨 Every treatment is STILL. The full-size heroes pan, parallax, crossfade
 * and tilt; a 200px band that does any of that sits above someone's reading
 * and competes with it. Slim declines the motion rather than restaging it.
 *
 * Adding a treatment? Add its [data-hero-slim="..."] block here AND map at
 * least one variant to it — scripts/check-hero-slim.mjs fails the push when a
 * mapped treatment has no rules, because a treatment with no stylesheet is
 * indistinguishable from the fallback it was meant to replace.
 * ------------------------------------------------------------------------ */

/* Shared slim geometry: every treatment is a BAND, not a screen. The top
   padding still has to clear the floating nav (~158px desktop), so the saving
   comes out of the bottom and the min-height, not the top. */
[data-hero-slim] .tn-page-header {
  min-height: 240px;
}
[data-hero-slim] .tn-page-header__content {
  padding: 168px 24px 28px;
}
[data-hero-slim] .tn-page-header__title {
  font-size: clamp(24px, 3.2vw, 38px);
}
[data-hero-slim] .tn-page-header__sub {
  font-size: clamp(13px, 1.1vw, 15px);
  margin-top: 10px;
}

/* centered — a/c/d/e/f/g. Image-backed heroes all reduce to this: the banner
   still, the title over it. Deliberately closest to the pre-2026-08-30
   header, because six of the fourteen variants land here and their tenants
   should see a tightening, not a redesign. */
[data-hero-slim="centered"] .tn-page-header__content {
  text-align: center;
}

/* editorial — b/i/l. These heroes are asymmetric; centring them throws away
   the one thing that makes them recognisable. Left-aligned, with the accent
   rule the literary/split heroes carry, and constrained so a long studio name
   does not run the full width. */
[data-hero-slim="editorial"] .tn-page-header {
  justify-content: flex-start;
}
[data-hero-slim="editorial"] .tn-page-header__content {
  text-align: left;
  max-width: var(--max-w, 1100px);
  padding-left: clamp(20px, 6vw, 88px);
}
[data-hero-slim="editorial"] .tn-page-header__title {
  max-width: 18ch;
}
[data-hero-slim="editorial"] .tn-page-header__sub {
  margin-left: 0;
  max-width: 52ch;
}
[data-hero-slim="editorial"] .tn-page-header__eyebrow::after {
  content: "";
  display: block;
  width: 46px;
  height: 2px;
  margin-top: 12px;
  background: var(--accent);
}
[data-hero-slim="editorial"] .tn-page-header__crumbs {
  justify-content: flex-start;
}

/* mark — k/m. Centred, but with breathing room above the title where the
   full-size hero puts its sigil/mark, and the eyebrow tracked out to read as
   an ornament rather than a label. */
[data-hero-slim="mark"] .tn-page-header__content {
  text-align: center;
  padding-top: 176px;
}
[data-hero-slim="mark"] .tn-page-header__eyebrow {
  letter-spacing: 0.42em;
  margin-bottom: 22px;
}
[data-hero-slim="mark"] .tn-page-header__title {
  letter-spacing: 0.01em;
}

/* mosaic — j/n. The full-size heroes are a grid of images; at band height
   there is room for a strip, not a grid. Keep the banner, veil it harder than
   the default so the type stays legible against a busier image, and let a
   hairline separate the band from the page. */
[data-hero-slim="mosaic"] .tn-page-header {
  border-bottom: 1px solid var(--rule);
}
/* 🚨 Veil opacity is a CONTRAST floor, not a taste setting. This treatment
   replaces a guaranteed 55-70% black scrim with a veil toward var(--bg), so
   the H1 (which is var(--text) here, with no text-shadow) is only legible to
   the extent the veil actually covers the photo. 74->86% keeps the effective
   surface within a few percent of the theme's own --bg/--text pair, which
   ThemeShell has already contrast-clamped; the image still reads through as
   texture. Do not lower these without re-scoring the pair. Same principle as
   hero_scrim (CLAUDE.md / BACKGROUND-SYSTEM.md): veiling TOWARD --bg always
   increases contrast against --text, veiling toward anything else does not. */
[data-hero-slim="mosaic"] .tn-page-header[data-banner="1"] .tn-page-header__veil {
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--bg) 74%, transparent) 0%,
    color-mix(in srgb, var(--bg) 86%, transparent) 100%
  );
}
[data-hero-slim="mosaic"] .tn-page-header[data-banner="1"] .tn-page-header__title {
  color: var(--text);
  text-shadow: none;
}
[data-hero-slim="mosaic"] .tn-page-header[data-banner="1"] .tn-page-header__sub {
  color: var(--muted);
}

/* bare — h. The countdown hero has nothing to reduce once the countdown is
   dropped, so this is type on the theme surface. Also the honest treatment
   for any future hero whose identity is a widget rather than an image. */
[data-hero-slim="bare"] .tn-page-header {
  min-height: 200px;
  background-image: none;
  border-bottom: 1px solid var(--rule);
}
/* 🚨 These three carry [data-banner="1"] to reach (0,3,0)+. PageHeader's own
   banner rules are `.tn-page-header[data-banner="1"] .tn-page-header__x`, i.e.
   (0,3,0) — an unqualified `[data-hero-slim="bare"] .tn-page-header__x` is
   only (0,2,0) and LOSES, so a page with a banner kept the black gradient and
   the white title while a page without one went bare. The mosaic block above
   qualifies for exactly this reason; this one did not, and the two treatments
   silently disagreed. */
[data-hero-slim="bare"] .tn-page-header__veil,
[data-hero-slim="bare"] .tn-page-header[data-banner="1"] .tn-page-header__veil {
  background: var(--bg);
}
[data-hero-slim="bare"] .tn-page-header__content {
  padding: 168px 24px 24px;
}
[data-hero-slim="bare"] .tn-page-header__title,
[data-hero-slim="bare"] .tn-page-header[data-banner="1"] .tn-page-header__title {
  color: var(--text);
  text-shadow: none;
}
[data-hero-slim="bare"] .tn-page-header[data-banner="1"] .tn-page-header__sub {
  color: var(--muted);
}

/* Phones. PageHeader's own @media rule is (0,1,0) and a media query adds no
   specificity, so without these the themed desktop values would also apply on
   a phone — the treatments have to restate their own mobile geometry. */
@media (max-width: 768px) {
  [data-hero-slim] .tn-page-header {
    min-height: 190px;
  }
  [data-hero-slim] .tn-page-header__content {
    padding-top: 92px;
    padding-bottom: 22px;
  }
  [data-hero-slim="mark"] .tn-page-header__content {
    padding-top: 100px;
  }
  [data-hero-slim="editorial"] .tn-page-header__content {
    padding-left: 20px;
  }
  [data-hero-slim="bare"] .tn-page-header {
    min-height: 160px;
  }
  [data-hero-slim="bare"] .tn-page-header__content {
    padding-top: 92px;
  }
}

/* --- Artist page: the detailed profile (2026-09-06) --------------------------
   Shop pills + facts row in the intro, then three sections that read the
   data we hold about ONE artist — where they work, what they do most, who
   they work beside — each null when its answer is not known. Token-pure per
   the rules at the top of this file. Theme knobs where a theme has a reason
   to differ, read with a fallback and never declared here:

     --ti-chip-radius   chip + pill corner            (999px)
     --ti-facts-rule    rule under the facts row       (1px)
     --ti-shop-rule     border of a shop card          (1px)
   ------------------------------------------------------------------------- */

/* Shop pills — "Spring, TX" under the name, linking to the shop page. */
.ti-shops { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 0; }
.ti-pill {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 6px 12px 6px 10px;
  font-family: var(--font-body);
  font-size: 13px; font-weight: 600; letter-spacing: 0.04em;
  color: var(--text);
  border: 1px solid var(--rule);
  border-radius: var(--ti-chip-radius, 999px);
  text-decoration: none;
}
.ti-pill:hover { border-color: var(--accent); color: var(--accent-text); }
.ti-pill__dot { width: 9px; height: 9px; background: var(--accent); border-radius: var(--ti-chip-radius, 999px); }
.ti-pill__state { color: var(--muted); font-weight: 500; }
.ti-header--center .ti-shops { justify-content: center; }

/* Facts row — pieces / styles / shops / since. Numbers in the display face. */
.ti-facts {
  display: flex; flex-wrap: wrap; gap: 0 32px;
  margin: 0 0 22px; padding: 14px 0;
  border-top: var(--ti-facts-rule, 1px) solid var(--rule);
  border-bottom: var(--ti-facts-rule, 1px) solid var(--rule);
}
/* 🚨 The LEDE — the facts row and the buttons sharing one band.
   Operator, 2026-09-19: "Contact can go into a shared row".

   The rule pair moves from `.ti-facts` onto this wrapper, and the two
   children give up their own margins and borders. That is the whole trick:
   two stacked bands each bounded by a hairline became one band bounded by
   the same hairlines, so the page loses a rule, two margins and a row of
   whitespace without either child being restyled anywhere else it appears.

   Scoped by DESCENDANT on purpose. `.ti-facts` also renders in ArtistIntroB
   and `.ti-actions` on the image-detail page; neither is inside `.ti-lede`,
   so neither moves. */
.ti-lede {
  display: flex; flex-wrap: wrap; align-items: center;
  justify-content: space-between; gap: 10px 28px;
  margin: 0 0 18px; padding: 12px 0;
  border-top: var(--ti-facts-rule, 1px) solid var(--rule);
  border-bottom: var(--ti-facts-rule, 1px) solid var(--rule);
}
.ti-lede > .ti-facts { margin: 0; padding: 0; border: 0; flex: 0 1 auto; }
.ti-lede > .ti-actions { margin: 0; flex: 0 1 auto; }
.ti-facts__item { display: flex; flex-direction: column-reverse; }
.ti-facts__value {
  margin: 0; font-family: var(--font-display);
  font-size: clamp(22px, 3vw, 30px); line-height: 1; color: var(--text);
}
.ti-facts__label {
  font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
  color: var(--muted); margin: 0 0 4px;
}
.ti-detail--stacked .ti-facts { justify-content: center; max-width: 560px; margin-left: auto; margin-right: auto; }

/* Shop card(s) — "Find {first} at". */
.ti-shop__grid { display: grid; gap: 20px; grid-template-columns: 1fr; }
@media (min-width: 720px) {
  .ti-shop__grid[data-count="2"],
  .ti-shop__grid[data-count="3"] { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
}
.ti-shop__card {
  padding: 24px;
  border: var(--ti-shop-rule, 1px) solid var(--rule);
  background: var(--bg-mid);
}
.ti-shop__name { margin: 0; font-family: var(--font-display); font-size: clamp(18px, 2.4vw, 24px); line-height: 1.15; }
.ti-shop__name a { color: var(--text); text-decoration: none; }
.ti-shop__name a:hover { color: var(--accent-text); }
.ti-shop__place { margin: 4px 0 12px; font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent-text); }
.ti-shop__addr { font-style: normal; font-size: 15px; line-height: 1.5; margin: 0 0 6px; color: var(--text); }
.ti-shop__phone { margin: 0 0 12px; font-size: 15px; }
.ti-shop__phone a { color: var(--accent-text); text-decoration: none; }
.ti-shop__phone a:hover { text-decoration: underline; text-underline-offset: 2px; }
.ti-shop__hours { margin: 0 0 12px; font-size: 14px; line-height: 1.6; color: var(--muted); }
.ti-shop__hours-row { display: flex; gap: 12px; justify-content: space-between; max-width: 340px; }
.ti-shop__hours dt { margin: 0; }
.ti-shop__hours dd { margin: 0; color: var(--text); }
.ti-shop__note { margin: 0 0 12px; font-size: 14px; line-height: 1.55; color: var(--muted); }
.ti-shop__actions { margin: 16px 0 0; }

/* On a phone the artist's work grid goes two-up, like the style rows under
   it — eight full-width tiles was a wall to scroll past before the rest of
   the page. Scoped to the artist page; /gallery keeps its own rhythm. */
@media (max-width: 480px) {
  [data-section="artist-work"] .ti-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* By style — one row per style with a count and a few tiles. */
.ti-styles__row { margin: 0 0 32px; }
.ti-styles__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; margin: 0 0 12px; }
.ti-styles__name { margin: 0; font-family: var(--font-display); font-size: clamp(17px, 2.2vw, 22px); line-height: 1.1; }
.ti-styles__count { font-size: 13px; color: var(--muted); }
.ti-styles__more { margin-left: auto; font-size: 13px; color: var(--accent-text); text-decoration: none; }
.ti-styles__more:hover { text-decoration: underline; text-underline-offset: 2px; }
.ti-styles__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (min-width: 720px) { .ti-styles__grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }

/* Also at {shop} — avatar row. */
.ti-peers__group { margin: 0 0 12px; }
.ti-peers__row { display: flex; flex-wrap: wrap; gap: 10px 22px; list-style: none; margin: 0; padding: 0; }
.ti-peers__item { margin: 0; }
.ti-peers__link { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; color: var(--text); }
.ti-peers__link:hover .ti-peers__name { color: var(--accent-text); }
.ti-peers__photo {
  width: 44px; height: 44px; object-fit: cover; flex: none;
  border-radius: var(--ti-chip-radius, 999px);
  background: var(--bg-mid); border: 1px solid var(--rule);
}
.ti-peers__photo--empty {
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--font-display); font-size: 14px; color: var(--muted);
}
.ti-peers__name { font-size: 15px; font-weight: 600; }

/* The artist FAQ is the homepage faq family's hairline list on a section
   keyed `artist-faq`, and sections.css scopes every faq-hairline rule to
   `[data-section="faq"]` — so this section shipped UNSTYLED on every themed
   tenant (measured 2026-09-06 on a live dev render: no padding, no rules, no
   italic question). The classes are shared on purpose; the scope is widened
   here, in the file every interior page loads, rather than by editing the
   home rules. */
[data-section="artist-faq"] .faq-hairline { max-width: 880px; margin: 0 auto; padding: 48px 0 8px; }
[data-section="artist-faq"] .faq-hairline__eyebrow {
  font-size: 11px; font-weight: 500; letter-spacing: 0.24em; text-transform: uppercase;
  color: var(--accent-text); margin-bottom: 14px; text-align: center;
}
[data-section="artist-faq"] .faq-hairline__headline {
  font-family: var(--font-display); font-weight: 500; font-size: clamp(26px, 3.4vw, 36px);
  letter-spacing: 0.03em; color: var(--text); margin: 0 0 40px; text-align: center;
}
[data-section="artist-faq"] .faq-hairline__item { padding: 24px 0; border-top: 1px solid var(--rule); }
[data-section="artist-faq"] .faq-hairline__item:last-of-type { border-bottom: 1px solid var(--rule); }
[data-section="artist-faq"] .faq-hairline__question {
  font-family: var(--font-display); font-style: italic; font-size: clamp(18px, 2.2vw, 22px);
  color: var(--text); margin: 0 0 12px; line-height: 1.3;
}
[data-section="artist-faq"] .faq-hairline__answer { font-size: 15px; color: var(--muted); line-height: 1.75; max-width: 720px; }

/* --- Artist video spot (2026-09-06) --------------------------------------
   One slot, two sources. A reel is framed by its OWN orientation (the player
   reads the file's dimensions); the generated teaser is an accelerating Ken
   Burns cut. Frames use padding-top ratios, not `aspect-ratio`, because the
   kiosk iPads (safari 12) do not know that property. Knobs:
     --ti-video-radius   frame corner (8px)                */
.ti-video { margin: 8px 0 0; }
.ti-video__frame {
  position: relative; width: 100%; overflow: hidden;
  background: var(--text); border-radius: var(--ti-video-radius, 8px);
}
/* 🚨 The ratio lives on a PSEUDO-ELEMENT, not on the frame itself.
   Percentage padding resolves against the CONTAINING BLOCK's width, not the
   element's own — so `max-width: 420px` + `padding-top: 177.78%` on one box
   gave a frame 420 wide and 1600 tall inside a 900px column (measured by
   scripts/check-teaser-frame.tsx: ratio 0.26 where 0.5625 was intended, and
   0.17 at 1400px). The cap and the ratio have to sit on different boxes.
   ::before is capped by the frame, so its percentage resolves against 420 and
   the shape holds at every width. padding-top rather than aspect-ratio because
   browserslist pins safari 12 for the kiosk iPads. */
.ti-video__frame::before { content: ''; display: block; }
.ti-video__frame[data-orientation="landscape"]::before { padding-top: 56.25%; }
/* A phone reel: 9:16, centred, never stretched to the column.
   🚨 A RATIO, not a viewport height. This was `height: min(78vh, 720px)`,
   which made the frame a different shape from the teaser that renders in the
   very same slot when a studio has no footage — and made it change shape with
   the window, so two tenants side by side never matched (operator,
   2026-09-07: *"the video slideshow has different dimensions"*). The teaser
   has always used a padding-top ratio; now both do, so the slot is one shape
   whichever fills it. padding-top rather than aspect-ratio because
   browserslist pins safari 12 for the kiosk iPads. */
.ti-video__frame[data-orientation="portrait"] {
  max-width: 420px;
  margin: 0 auto;
}
.ti-video__frame[data-orientation="portrait"]::before { padding-top: 177.78%; }
.ti-video__media {
  position: absolute; inset: 0; width: 100%; height: 100%;
  /* 🚨 FILL, do not letterbox. `contain` was pillarboxing every reel whose
     source was not exactly 9:16 — black bars inside a frame that is itself
     black, so it read as a broken player rather than a fitted one. `cover`
     fills the frame and crops the overflow, which is what a fixed-ratio
     frame is for; it never distorts, because object-fit preserves the
     source's own aspect either way. */
  object-fit: cover; display: block; background: var(--text);
}

/* The teaser.
   🚨 Scrims and text over PHOTOGRAPHS are theme-agnostic on purpose: a black
   scrim with white type reads on any picture under any theme, whereas
   var(--bg)/var(--text) would put near-black type on a black scrim on every
   dark theme. These rgba/white literals are acknowledged in
   css-color-baseline.json for exactly that reason — they are not brand
   colours and must not become tokens. */
.ti-teaser {
  position: relative; overflow: hidden;
  background: #000000; color: #ffffff;
  border-radius: var(--ti-video-radius, 8px);
  /* Lifts the reel off the page instead of butting a black slab against the
     surface behind it (operator, 2026-09-08: *"how about some drop shadow
     action on cards/buttons/slideshow?"*). Carries most on the light interior
     themes, where a 16:9 black band otherwise reads as a hole in the page;
     on a dark theme it is close to invisible, which is the correct amount.
     A knob, so a theme that wants none can set it to `none`. */
  box-shadow: var(--ti-teaser-shadow, 0 24px 60px rgba(0, 0, 0, 0.28));
}
/* 🚨 ONE SHAPE PER SLOT, and it is the preset's, not the viewport's.
   The reel player next door (.ti-video__frame) was moved to a 177.78% / 56.25%
   ratio pair on 2026-09-07 so "the slot is one shape whichever fills it" — but
   only the REEL half was changed. The teaser, which fills that very same slot
   when a studio has no footage, was still 56.25% with a 125% phone override:
   16:9 on desktop, 4:5 on a phone, and never the 9:16 the design called for on
   any viewport. So the mismatch that fix set out to remove still existed, in
   the other direction. Both halves now use the same two ratios and the same
   420px portrait cap.
   The frame comes from the preset's orientation (data-orientation on the root),
   NOT from a media query — a 9:16 reel is 9:16 on a desktop too; it just gets
   capped and centred rather than reshaped.
   padding-top rather than aspect-ratio because browserslist pins safari 12 for
   the kiosk iPads. */
.ti-teaser__stage { position: relative; width: 100%; padding-top: 177.78%; overflow: hidden; }
.ti-teaser[data-orientation="landscape"] .ti-teaser__stage { padding-top: 56.25%; }
/* 🚨 A landscape reel has no intrinsic ceiling — it is 56.25% of whatever width
   it is handed, so a full-bleed placement on a 1440px page resolves to 810px
   and eats 84% of the first viewport. Measured on /about 2026-09-12, which is
   the "extra space around the new slideshows" reported that day: the page
   opened on a black 16:9 band and every word of the About copy sat below the
   fold. The cap is a KNOB rather than a fixed value because the right ceiling
   belongs to the placement, not to the reel: a hero wants more of the screen
   than a supporting band midway down a page. `overflow: hidden` on the root
   already clips the stage, and the CTA, Pause and progress ticks are positioned
   against that same root, so they stay inside the capped frame rather than
   hanging off the bottom. */
/* 🚨 A height cap on a landscape band is expressed as a WIDTH cap, because
   `max-height` CLIPS it. The stage is sized by `padding-top: 56.25%` off the
   WIDTH, so capping the height leaves the bottom of the stage hanging outside
   the box — measured on a live shop page: teaser 1100x468 against a stage of
   1100x619, so 151px of it was cut off. Beats at `bottom: 16%` are positioned
   against the STAGE, so a lower-third caption landed in that hidden strip and
   rendered sliced through the middle of its letters (operator screenshot,
   2026-09-14: "text is getting cut off sometimes in this slideshow").

   Deriving a max-width from the same knob keeps the two boxes the same size,
   so nothing can fall outside. The band gets NARROWER rather than shorter —
   the page's vertical rhythm is unchanged, which is the half the operator has
   already signed off on. The 100000px default is "no cap" without needing a
   second selector. Centred, because a capped band in a wider column should
   not hug the left edge. */
/* 🚨 `width: 100%` is load-bearing, not tidying. Auto margins in the CROSS axis
   of a flex container beat `align-self: stretch`, so a flex item with
   `margin-left/right: auto` and no definite width shrink-to-fits instead of
   filling. Every child of this teaser is absolutely positioned and the stage is
   sized by `padding-top: %` off its own width, so shrink-to-fit resolves to
   ZERO: measured on /piercing 2026-09-18, whose `.pi` wrapper is
   `display: flex; flex-direction: column`, the reel came out 0x0 and the page
   showed a blank band where the slideshow should be. It rendered correctly on
   /about and the shop pages only because those wrap it in a plain block div.
   Stating the width makes the cross size definite, so the cap still caps and
   the auto margins still centre. Same for the portrait rule below. */
.ti-teaser[data-orientation="landscape"] {
  width: 100%;
  max-width: calc(var(--ti-teaser-max-h, 100000px) * 16 / 9);
  margin-left: auto;
  margin-right: auto;
}
/* The cap and centring go on the ROOT, because the pinned CTA, the Pause and
   the progress ticks are positioned against it — capping the stage alone would
   leave the controls hanging outside the visible frame. */
.ti-teaser[data-orientation="portrait"] {
  width: 100%;
  max-width: var(--ti-teaser-max, 420px);
  margin-left: auto;
  margin-right: auto;
}
/* 🚨 A LANDSCAPE teaser turns portrait on a phone, and this is the one place
   the shape is allowed to come from the viewport rather than the preset
   (operator, 2026-09-18: *"should these slideshows be horizontal desktop and
   verticle for phone?"*). The rule above says one shape per slot; the reason
   it bends here is arithmetic, not taste: 56.25% of a 390px phone is a 219px
   band, which is too short to read a tattoo in and is most of why the reel
   looked sparse on a phone. On desktop the same reel is 585px and correct.

   🚨 It bends for the TEASER ONLY. `.ti-video__frame` next door plays real
   footage, and re-cropping a 16:9 video to a tall frame throws away the sides
   of a shot somebody framed — a still cropped by `object-fit: cover` loses
   nothing that was not already a crop. So that component keeps the preset's
   shape on every viewport and the original rule stands for it.

   4:5 rather than 9:16 by default: portrait unmistakably, at 487px on a 390px
   phone — about the same share of the viewport (62%) that the desktop band
   takes of a 1200px one (49%). 9:16 would be 693px, i.e. 89%, which is a hero
   and not a supporting band. A placement that IS a hero raises the knob. */
@media (max-width: 719px) {
  .ti-teaser[data-orientation="landscape"] .ti-teaser__stage {
    padding-top: var(--ti-teaser-phone-ratio, 125%);
  }
  /* The desktop cap is a width cap derived from a height budget (see above);
     on a phone the band is no longer 16:9, so that derivation does not hold
     and the reel simply fills the column. */
  .ti-teaser[data-orientation="landscape"] { max-width: none; }
}
.ti-teaser__slide {
  position: absolute; inset: 0; margin: 0; overflow: hidden;
  opacity: 0;
  transition: opacity var(--ti-fade, 200ms) linear;
}
.ti-teaser__slide[data-on="yes"] { opacity: 1; z-index: 2; }

/* --- Cut-to-cut transitions (data-transition on the root) -----------------
   🚨 OPACITY AND TRANSFORM ONLY. `browserslist` pins `safari 12` for the
   in-studio kiosk iPads, which rules out clip-path wipes and filter
   dissolves; those two properties are also the only ones the compositor can
   animate without a re-layout, which is what keeps a 14-cut reel smooth on a
   2014 iPad.

   🚨 These transform the SLIDE. The Ken Burns scale lives on the `<img>`
   inside it, so the two never contend for one `transform` — which is the
   whole reason a push could be added without touching the zoom (operator,
   2026-09-18: "Ken burns is fine, add the other transition options").

   No `data-transition`, or an unknown one, leaves the base crossfade above
   untouched — a reel can never end up with no transition by accident. */

/* A film edit: no blend. The fade is removed rather than shortened, so the
   swap lands on one frame instead of a 1-frame dissolve nobody asked for. */
.ti-teaser[data-transition="cut"] .ti-teaser__slide { transition: none; }

/* The incoming cut travels in under the fade. 6%, not 100%: the slide is
   `position: absolute; inset: 0` over a black root, so anything that clears
   the frame shows a black band the width of the travel. 6% reads as a push
   and keeps the photograph covering the stage. */
.ti-teaser[data-transition="push"] .ti-teaser__slide {
  transform: translate3d(6%, 0, 0);
  transition:
    opacity var(--ti-fade, 200ms) linear,
    transform var(--ti-fade, 200ms) cubic-bezier(0.22, 1, 0.36, 1);
}
.ti-teaser[data-transition="push"] .ti-teaser__slide[data-on="yes"] {
  transform: translate3d(0, 0, 0);
}

/* The same, vertically. Quieter, and it suits the 9:16 frame where a
   horizontal travel has less room to read. */
.ti-teaser[data-transition="rise"] .ti-teaser__slide {
  transform: translate3d(0, 5%, 0);
  transition:
    opacity var(--ti-fade, 200ms) linear,
    transform var(--ti-fade, 200ms) cubic-bezier(0.22, 1, 0.36, 1);
}
.ti-teaser[data-transition="rise"] .ti-teaser__slide[data-on="yes"] {
  transform: translate3d(0, 0, 0);
}
.ti-teaser__img {
  display: block; width: 100%; height: 100%; object-fit: cover;
  /* FILL, never letterbox or distort — object-fit preserves the source's own
     aspect either way, so a fixed-ratio frame crops rather than stretches.
     --kb-pos centres the crop on the piece's focal point when the reel lab has
     set one; with none it falls back to centre, which is what every slide did
     before focal points existed. */
  object-position: var(--kb-pos, center);
  transform: scale(var(--ti-kb-from, 1.02));
  will-change: transform;
}
/* The Ken Burns: one animation per cut, whose DURATION is the cut length.
   The scheduler shortens each cut, so the zoom itself gets faster — that is
   what makes it read as acceleration rather than as a slideshow that skips. */
.ti-teaser__slide[data-on="yes"] .ti-teaser__img {
  animation: ti-kb var(--kb-dur, 2000ms) linear forwards;
}
@keyframes ti-kb {
  from { transform: scale(var(--ti-kb-from, 1.02)) translate(0, 0); }
  to   { transform: scale(var(--ti-kb-to, 1.18)) translate(var(--kb-x, -6%), var(--kb-y, 0)); }
}
/* 🚨 The bottom band of the stage is masked, and that is a fix, not a
   flourish. The Ken Burns zooms 1.02 -> 1.18 and pans, so the bottom edge of
   the photograph travels across the cut — on a wide reel that reads as the
   FRAME shifting rather than the picture moving, which is the one thing a
   fixed-ratio stage must never look like it is doing (operator, 2026-09-08:
   *"the bottom 20% (or so) of the slideshow should be a black mask so the
   zooming images bottom doesnt shift"*). The band also gives the lower-third
   type its ground, which is why it must sit ABOVE the image and BELOW the
   text.
   🚨 The z-indexes here are EXPLICIT and measured, not decorative. DOM order
   does not settle this: .ti-teaser__img carries `transform: scale()`, which
   paints it in the positioned-descendants step as though it were z-index 0 —
   so a plain ::before, positioned but with z-index auto and EARLIER in the
   DOM, paints underneath it. Probed in Chromium against a solid white slide:
   the band came back #ffffff, i.e. the mask was there and invisible. The
   three layers are therefore stated: image 0 (implicit, via its transform),
   mask 1, text 2. The slide is itself `position: absolute; z-index: 2`, so
   it is a stacking context and none of these escape to fight the end card
   (3), the pause button (4) or the pinned CTA (5).
   Colour and height are knobs. The default is the component's own black — it
   already sets `background: #000` with white type for the reason recorded
   above, so brand colours are opt-in per theme rather than assumed. */
.ti-teaser__slide::before {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: var(--ti-teaser-mask-h, 22%);
  z-index: 1;
  pointer-events: none;
  background: var(
    --ti-teaser-mask,
    linear-gradient(to top, #000000 0%, rgba(0, 0, 0, 0.72) 42%, rgba(0, 0, 0, 0) 100%)
  );
}
.ti-teaser__text { position: absolute; inset: 0; margin: 0; pointer-events: none; z-index: 2; }
/* The piece's own style tag: small, lower-left, the one constant. */
/* 🚨 TOP-left, because the bottom-left corner has THREE tenants and the caption
   is the one that does not need to be there.
   History, because two wrong answers were shipped before this one. The caption
   sat at `bottom: 8%`; a pass on 2026-09-08 moved it to `bottom: 34px` to clear
   the progress row, reading `bottom: 16px` at .ti-teaser__progress and missing
   that `.ti-teaser[data-state] .ti-teaser__progress` overrides it to 52px 140
   lines further down - every teaser carries data-state, so the ticks were never
   at 16px. 34px put the caption INSIDE .ti-teaser__cta (bottom 12px, opaque,
   z-index 5 against the caption's 2), so it went behind a button.
   Measured in Chromium with a CTA present, at 390/900/1400px in both
   orientations: `bottom: 34px` hits the CTA 6 times out of 6; the ORIGINAL
   `bottom: 8%` hits the CTA 4 times and the ticks 6 times, so reverting is not
   the fix either; `top: 14px` is clean in all six. The pinned CTA is on by
   default for every ReelEmbed and always on BoardPhone, so the collision is the
   common case, not the edge one. */
.ti-teaser__caption {
  position: absolute; left: 4%; top: 14px;
  font-family: var(--font-body); font-size: 12px; font-weight: 600;
  letter-spacing: 0.18em; text-transform: uppercase;
  color: #ffffff; opacity: 0.85; text-shadow: 0 1px 10px rgba(0, 0, 0, 0.55);
  animation: ti-cap var(--kb-dur, 2000ms) linear forwards;
}
/* The beats: lines of the artist's own info, big, one per cut, moving
   around the frame so the eye follows the edit. Entrance is a fraction of
   the cut (ti-cap's 12%), so on a 320 ms cut the line is fully on in ~40 ms
   — a hit, not a fade. */
.ti-teaser__beat {
  position: absolute; max-width: 78%;
  display: flex; flex-direction: column; gap: 6px;
  color: #ffffff;
  /* 🚨 A DRAMATIC drop shadow, not the soft 18px glow this used to carry
     (operator, 2026-09-21: "a black stroke or even better a dramatic drop
     shadow"). Four layers doing two different jobs: three tight, opaque
     offsets that read as a hard edge against a busy photograph — the job a
     stroke would do — and one wide soft pool that separates the whole block
     from a bright cut. A real `-webkit-text-stroke` was the other option and
     is rejected: it thins the counters of a display face at these sizes, and
     on the 40-72px fact figures that is the difference between a 0 and an 8.
     text-shadow is also the safer of the two under the `safari 12` kiosk pin.
     Plain rgba because color-mix() is banned by that same pin. */
  text-shadow:
    0 2px 0 rgba(0, 0, 0, 0.9),
    2px 2px 0 rgba(0, 0, 0, 0.7),
    -2px 2px 0 rgba(0, 0, 0, 0.7),
    0 8px 28px rgba(0, 0, 0, 0.85);
  animation: ti-cap var(--kb-dur, 2000ms) linear forwards;
}
.ti-teaser__beat[data-pos="bl"] { left: 4%; bottom: 16%; text-align: left; }
/* 🚨 The two CENTRED positions state a `width`, not just a `max-width`, and
   that is the fix for names breaking mid-word. An absolutely positioned box
   with `left` set and no `right` shrink-to-fits into `containing block - left`
   — so `left: 50%` hands a centred beat exactly HALF the stage, and the 86%
   cap it looks like it has never applies. Measured on the /piercing reel at
   390px 2026-09-18: a 342px stage gave the beat 171px and rendered
   "COURTNE / Y  MORRISO / N", because `word-break: break-word` on the name
   then had nowhere else to break. Stating the width makes the box definite,
   so the percentage is the one in the rule and the translate still centres
   it. Desktop is unchanged in practice — 520px of available space was already
   enough for the names that fit. */
.ti-teaser__beat[data-pos="c"]  { left: 50%; top: 50%; text-align: center; align-items: center; width: var(--ti-beat-w, 86%); max-width: 86%; animation-name: ti-cap-c; }
.ti-teaser__beat[data-pos="tr"] { right: 4%; top: 8%; text-align: right; align-items: flex-end; }
.ti-teaser__beat[data-pos="bc"] { left: 50%; bottom: 16%; text-align: center; align-items: center; width: var(--ti-beat-w, 78%); animation-name: ti-cap-bc; }
/* The artist's own face, on the beat that says their name.
   Operator, 2026-09-08: "doesn't look like the artist photo is there/used".
   It WAS - on the end card, which on the shipped curve arrives about ten
   seconds in and holds for three, so almost nobody saw it. A reel that opens
   on a name is the one place a face belongs: it is the difference between a
   caption and a person. Rides the beat's existing entrance, so it costs no new
   animation, and follows the beat's own align-items so it sits correctly
   whichever corner the beat is cycled into. */
.ti-teaser__face {
  /* 🚨 A px fallback FIRST, and it is not decorative. browserslist pins
     safari 12 for the kiosk iPads and Safari 12 has no clamp(), so both
     declarations drop and an <img> with no width or height falls back to its
     INTRINSIC size - a full-size photograph in the middle of the frame. A
     font-size that falls back is a smaller word; this one is a broken layout,
     which is why this rule gets a fallback where the type in this file does
     not. */
  width: 56px; height: 56px;
  width: clamp(44px, 7vw, 72px); height: clamp(44px, 7vw, 72px);
  object-fit: cover;
  border-radius: var(--ti-chip-radius, 999px);
  border: 2px solid var(--accent);
}
.ti-teaser__kicker {
  font-family: var(--font-body); font-size: clamp(11px, 1.3vw, 14px); font-weight: 600;
  letter-spacing: 0.2em; text-transform: uppercase; opacity: 0.85;
}
/* 🚨 The FACT kicker is bigger than every other kicker, because on a shop reel
   it is not a label — it is half the sentence. "MON - THU" over "2:00 PM -
   10:00 PM" only means anything if you can read the days at the speed the band
   moves, and at 11px over a photograph you could not (operator, 2026-09-21).
   Scoped to `data-kind="fact"` so the artist reel's kickers, which really are
   labels above a name, keep their old size. */
.ti-teaser__beat[data-kind="fact"] .ti-teaser__kicker {
  font-size: clamp(15px, 2.1vw, 22px);
  letter-spacing: 0.14em;
  opacity: 1;
}
.ti-teaser__line {
  font-family: var(--font-display); text-transform: uppercase;
  font-size: clamp(26px, 5.4vw, 68px); line-height: 0.95; letter-spacing: 0.01em;
  text-wrap: balance;
}
/* 🚨 The caps are 72px / 92px because THE FRAME IS FIXED AND THE UNIT IS NOT.
   These beats sized off the VIEWPORT (`8vw` / `10vw`) while their box is
   capped at `--ti-teaser-max`, 420px — so past roughly a 940px viewport the
   type outgrew the frame and `.ti-teaser__stage`'s `overflow: hidden` ate the
   difference. Measured on a live artist reel at 1440: the name line computed
   104px and rendered 588px wide inside 420px, losing 84px off EACH end, so
   "COURTNEY MORRISON" read as COURTNE / Y / ORRISO. At 1024 it still lost
   22px a side; only below ~940px did it fit. The artist's own name, on the
   largest element of their page.

   A container query would be the right tool and is not available: the
   `safari 12` browserslist pin is load-bearing for the kiosk iPads, and
   `cqw` would be dropped there — as would `:is()` and `aspect-ratio`. So the
   cap is set to what the 420px frame can actually hold: "COURTNEY" measures
   ~407px at 72px. `word-break` is the backstop for a name longer than that —
   a broken word is legible, a clipped one is not.

   🚨 Anything that changes `--ti-teaser-max` has to revisit these two caps. */
.ti-teaser__beat[data-kind="name"] .ti-teaser__line {
  font-size: clamp(34px, 8vw, 72px);
  overflow-wrap: break-word;
  word-break: break-word;
}
.ti-teaser__beat[data-kind="count"] .ti-teaser__line { font-size: clamp(48px, 10vw, 92px); }
/* Sentences (tagline, bio) are prose, not a headline: body face, sentence
   case, a readable measure. */
.ti-teaser__beat[data-kind="tagline"] .ti-teaser__line,
.ti-teaser__beat[data-kind="bio"] .ti-teaser__line {
  font-family: var(--font-body); text-transform: none; font-weight: 500;
  font-size: clamp(17px, 2.4vw, 30px); line-height: 1.25; letter-spacing: 0; max-width: 32ch;
}
.ti-teaser__beat[data-kind="handle"] .ti-teaser__line { font-size: clamp(20px, 3.4vw, 44px); letter-spacing: 0.04em; }
/* "Books open" is the one beat that is news, so it gets the gold — as a
   SURFACE under the word, never as its colour (check-css-vocab R-8b). */
.ti-teaser__beat[data-kind="status"] .ti-teaser__line { border-bottom: 5px solid var(--accent); padding-bottom: 4px; }
/* FACT beats — what the SHOP says, between the lines about its people
   (lib/themes/interior/reel-facts.ts). One kind, two typographic objects, told
   apart by whether the beat carries a kicker:

     no kicker   a sentence the studio authored ("Every piercing price includes
                 your starter jewelry"). Prose, so it takes the tagline/bio
                 treatment — body face, sentence case, a 32ch measure. Set in
                 the display face at 68px it would read as a slogan we wrote.

     kicker      a row off the studio's own price list: "EAR LOBE" small above
                 "$40" large. The figure is the thing you read at band speed,
                 so it takes the `count` size. Capped at 72px rather than
                 count's 92px because a price carries a currency mark and a
                 second digit where a piece count is often one character.

   🚨 Sized against the SAME fixed frame the name cap note above is about: the
   box is `--ti-teaser-max`, not the viewport, so a vw unit past ~940px outgrows
   it and `overflow: hidden` eats the ends. */
.ti-teaser__beat[data-kind="fact"][data-kicker="no"] .ti-teaser__line {
  font-family: var(--font-body); text-transform: none; font-weight: 500;
  font-size: clamp(17px, 2.4vw, 30px); line-height: 1.25; letter-spacing: 0; max-width: 32ch;
}
.ti-teaser__beat[data-kind="fact"][data-kicker="yes"] .ti-teaser__line {
  font-size: clamp(40px, 8vw, 72px);
}
@media (max-width: 719px) {
  /* The phone gets a wider measure, and the centred beats need the knob as
     well as the cap — see the width note above. */
  .ti-teaser__beat { max-width: 88%; --ti-beat-w: 88%; }
  .ti-teaser__beat[data-pos="tr"] { top: 6%; }
}
@keyframes ti-cap {
  from { opacity: 0; transform: translateY(6px); }
  12%  { opacity: 1; transform: translateY(0); }
  to   { opacity: 1; transform: translateY(0); }
}
@keyframes ti-cap-c {
  from { opacity: 0; transform: translate(-50%, calc(-50% + 6px)); }
  12%  { opacity: 1; transform: translate(-50%, -50%); }
  to   { opacity: 1; transform: translate(-50%, -50%); }
}
@keyframes ti-cap-bc {
  from { opacity: 0; transform: translate(-50%, 6px); }
  12%  { opacity: 1; transform: translate(-50%, 0); }
  to   { opacity: 1; transform: translate(-50%, 0); }
}
/* --- Kinetic text (preset textMotion: "kinetic") -------------------------
   The offer-ad studio's entrance vocabulary, re-homed. The KEYFRAMES port from
   components/marketing/TattooNOWVideoAd as they are — transform and opacity
   only, which every browser we ship to runs, and the same cubic-bezier(0.16,
   1, 0.3, 1) it uses. What did NOT port is that component's sizing: it scales
   type with container queries (container-type: size, cqw/cqh), which do not
   resolve on the kiosk iPads, so type here stays on the clamp()+vw scale the
   rest of this file uses.

   🚨 Under kinetic the CONTAINER stops animating and its children animate
   instead, so the entrance must restate the centring transform that ti-cap-c /
   ti-cap-bc were carrying — drop it and every centred beat jumps to the
   frame's left edge. Same reason the reduced-motion block below restates them.

   Entrances are a FRACTION of the cut, like ti-cap's 12%, so a 320 ms cut
   still lands the line instead of being caught mid-flight. */
.ti-teaser[data-motion="kinetic"] .ti-teaser__beat { animation: none; opacity: 1; }
.ti-teaser[data-motion="kinetic"] .ti-teaser__beat[data-pos="c"] { transform: translate(-50%, -50%); }
.ti-teaser[data-motion="kinetic"] .ti-teaser__beat[data-pos="bc"] { transform: translateX(-50%); }
.ti-teaser[data-motion="kinetic"] .ti-teaser__kicker {
  animation: ti-beat-left var(--kb-dur, 2000ms) cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.ti-teaser[data-motion="kinetic"] .ti-teaser__line {
  animation: ti-beat-right var(--kb-dur, 2000ms) cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* A number and a books-open status are hits, not sentences: they punch in
   rather than slide, which is the offer ad's tnva-pop. */
.ti-teaser[data-motion="kinetic"] .ti-teaser__beat[data-kind="count"] .ti-teaser__line,
.ti-teaser[data-motion="kinetic"] .ti-teaser__beat[data-kind="status"] .ti-teaser__line {
  animation-name: ti-beat-pop;
}
@keyframes ti-beat-left {
  from { opacity: 0; transform: translateX(-12%); }
  14%  { opacity: 1; transform: translateX(0); }
  to   { opacity: 1; transform: translateX(0); }
}
@keyframes ti-beat-right {
  from { opacity: 0; transform: translateX(12%); }
  18%  { opacity: 1; transform: translateX(0); }
  to   { opacity: 1; transform: translateX(0); }
}
@keyframes ti-beat-pop {
  from { opacity: 0; transform: scale(0.86); }
  16%  { opacity: 1; transform: scale(1); }
  to   { opacity: 1; transform: scale(1); }
}

.ti-teaser__preload { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }

/* The end card — name, shop, styles, Book — over the last frame. */
.ti-teaser__card {
  position: absolute; inset: 0; z-index: 3;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 10px; padding: 24px; text-align: center;
  background: rgba(0, 0, 0, 0.55);
  opacity: 0; pointer-events: none;
  transition: opacity 260ms ease-out;
}
.ti-teaser__card[data-on="yes"] { opacity: 1; pointer-events: auto; }
.ti-teaser__card-photo {
  width: 72px; height: 72px; object-fit: cover;
  border-radius: var(--ti-chip-radius, 999px); border: 2px solid var(--accent);
}
.ti-teaser__card-name {
  font-family: var(--font-display); font-size: clamp(28px, 6vw, 64px);
  line-height: 0.95; text-transform: uppercase; color: #ffffff;
}
.ti-teaser__card-meta {
  display: flex; flex-wrap: wrap; justify-content: center; gap: 6px 18px;
  font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; color: #ffffff; opacity: 0.85;
}
.ti-teaser__book { margin-top: 8px; }
/* A minimal card is the name and the ask, with room to breathe. The photo and
   meta rows are not hidden here — the component does not render them at all,
   so there is nothing to hide and no empty gap left behind.
   🚨 It keeps the SAME 0.55 scrim as the full card deliberately. A lighter
   scrim would let more of the last piece through, but white type over an
   arbitrary photograph is only reliably readable because that scrim is dark
   enough — which is the whole reason these are theme-agnostic black/white
   literals (see the note at the top of this block). The two cards differ in
   what they SAY, never in whether you can read them. */
.ti-teaser[data-endcard="minimal"] .ti-teaser__card { gap: 16px; }

/* Controls: a visible Pause (a loop without one is the thing the theme's
   motion rules forbid), and a tick per cut so the acceleration is legible. */
.ti-teaser__toggle {
  position: absolute; right: 12px; bottom: 12px; z-index: 4;
  padding: 7px 12px; font-family: var(--font-body); font-size: 12px; font-weight: 600;
  letter-spacing: 0.08em; text-transform: uppercase;
  color: #ffffff; background: rgba(0, 0, 0, 0.55); border: 1px solid rgba(255, 255, 255, 0.35);
  border-radius: var(--ti-chip-radius, 999px); cursor: pointer;
}
.ti-teaser__toggle:hover, .ti-teaser__toggle:focus-visible { background: rgba(0, 0, 0, 0.8); outline: 2px solid var(--accent); outline-offset: 2px; }
.ti-teaser__progress {
  position: absolute; left: 12px; right: 96px; bottom: 16px; z-index: 4;
  display: flex; gap: 3px;
}
.ti-teaser__tick { flex: 1; height: 3px; background: rgba(255, 255, 255, 0.3); }
.ti-teaser__tick[data-done="yes"] { background: var(--accent); }
.ti-teaser__tick[data-done="now"] { background: #ffffff; }
.ti-teaser[data-state="card"] .ti-teaser__progress { opacity: 0; }

@media (prefers-reduced-motion: reduce) {
  /* No motion until asked for: the component does not autoplay under this
     query, and if the visitor presses Play the cuts run but the zoom does not. */
  .ti-teaser__slide[data-on="yes"] .ti-teaser__img { animation: none; transform: scale(var(--ti-kb-from, 1.02)); }
  /* 🚨 The travel goes, the reel does not. Someone who has asked for less
     motion still gets the work — every transition collapses to the plain
     opacity swap rather than the slides sitting permanently offset, which is
     what happens if only the `transition` property is removed. */
  .ti-teaser[data-transition="push"] .ti-teaser__slide,
  .ti-teaser[data-transition="rise"] .ti-teaser__slide { transform: none; }
  .ti-teaser__caption, .ti-teaser__beat { animation: none; opacity: 1; }
  .ti-teaser__beat[data-pos="c"] { transform: translate(-50%, -50%); }
  .ti-teaser__beat[data-pos="bc"] { transform: translateX(-50%); }
  /* The kinetic presets animate the kicker and the line themselves, so
     stopping the container is not enough to stop the motion. */
  .ti-teaser__kicker, .ti-teaser__line { animation: none; opacity: 1; transform: none; }
}

/* --- /piercing (2026-09-06) ----------------------------------------------
   The price list is the page. Set as a leader-dot list rather than a table:
   it is two columns of one line each, and a real <table> would promise a
   grid it does not have. Token-pure; theme knobs where a theme needs one. */
/* 🚨 Rhythm, not one value repeated. Every block sat at the same
   clamp(36px,5vw,64px), so the at-a-glance figures, the price list, the
   piercers and the gallery all carried equal weight and the page read as
   stacked bands rather than a composition (measured 2026-09-14: 64 / 64 / 63
   between all four). The stats belong TO the price list they summarise, so
   they sit tight under the head and the generous interval opens between the
   major blocks instead. */
/* 🚨 The rhythm is TIGHTER than the rest of the interior on purpose. This
   page is a price list, a roster and a grid — a stack of reference blocks a
   visitor scans rather than reads — so an 80px trough between each one
   (6vw, which maxes out on any desktop) pushed the slideshow down the page
   and left the first screen mostly empty. Operator, 2026-09-18: "tighten up
   the white space. bring up the slideshow". 4vw/56px keeps the blocks
   clearly separate at a third less cost. */
.pi {
  /* 🚨 The trough is a TOKEN because two rules need it: this gap, and
     `.pi-stats`'s negative bottom margin, which cancels it so the figures sit
     tight to the price list they describe. Written twice, they drift — and
     they did: the gap was tightened from clamp(44px, 6vw, 80px) to this on
     2026-09-18 while the margin still cancelled the old value, so it
     over-pulled by 24px and the stats row rendered ON TOP of "What it costs"
     (measured: .pi-stats ended at y=1057, the next block began at 1053). */
  --pi-gap: clamp(32px, 4vw, 56px);
  display: flex;
  flex-direction: column;
  gap: var(--pi-gap);
}
.pi-block { margin: 0; }
/* At-a-glance counts above the price list. Every figure is computed from the
   tenant's own rows and omitted when zero — a placeholder number on a page
   whose job is to be trusted is a claim. */
.pi-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 16px 24px;
  list-style: none;
  /* 🚨 Pulled tight to the block BELOW, by exactly the flex gap. These
     figures describe the price list that follows, and sitting at the same
     clamp() interval as every other block they read as a fourth band of their
     own. Proximity does the grouping — no rule, no card: the price rows
     already carry borders and another line here would be decoration
     compensating for weak spacing. The negative margin cancels the parent gap
     and leaves 20px — reading --pi-gap, never a second copy of the clamp. */
  margin: 0 0 calc(20px - var(--pi-gap, 56px));
  padding: 0;
}
.pi-stat { display: grid; gap: 4px; }
.pi-stat__v {
  font-family: var(--font-display, serif);
  font-size: clamp(26px, 4vw, 38px);
  line-height: 1;
  color: var(--text);
}
.pi-stat__l {
  font-family: var(--font-body);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent-text);
}
.pi-h2 {
  font-family: var(--font-display); color: var(--text);
  font-size: clamp(22px, 3.2vw, 32px); line-height: 1.05; margin: 0 0 10px;
  text-wrap: balance;
}
.pi-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
.pi-more { font-size: 13px; color: var(--accent-text); text-decoration: none; white-space: nowrap; }
.pi-more:hover { text-decoration: underline; text-underline-offset: 2px; }
.pi-note { margin: 0 0 20px; max-width: 62ch; color: var(--muted, var(--text)); line-height: 1.6; }

.pi-prices { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
@media (min-width: 760px) { .pi-prices { grid-template-columns: 1fr 1fr; column-gap: 48px; } }
.pi-price {
  display: grid; grid-template-columns: auto 1fr auto; align-items: baseline; gap: 8px;
  padding: 11px 0; border-bottom: 1px solid var(--rule);
  font-family: var(--font-body); font-size: 15.5px;
}
.pi-price__label { color: var(--text); }
.pi-price__dots { border-bottom: 1px dotted var(--rule); transform: translateY(-3px); }
.pi-price__value {
  font-family: var(--font-display); color: var(--text);
  font-size: 17px; font-variant-numeric: tabular-nums; white-space: nowrap;
}
.pi-price__note { grid-column: 1 / -1; font-size: 13px; color: var(--muted, var(--text)); margin-top: 2px; }
/* 🚨 A LINKED price row. The anchor becomes the grid, not a child of it —
   wrapping the three cells in an `<a>` inside the `<li>` would have made the
   anchor the row's only grid item and collapsed label, dots and figure into
   one run of inline text.

   The row must read IDENTICALLY whether or not it links: same columns, same
   baseline, same padding, no underline and no colour change. A price list
   where the linked services look different from the unlinked ones invites
   the reader to wonder what the difference means, and the answer — "we have
   photos tagged for this one" — is about our data, not their piercing.
   `:has()` would pick the linked rows out without a modifier class and is
   banned here: `browserslist: safari 12` is load-bearing for the kiosk
   iPads. Hence the explicit `pi-price--link`. */
.pi-price--link { display: block; padding: 0; }
.pi-price__link {
  display: grid; grid-template-columns: auto 1fr auto; align-items: baseline; gap: 8px;
  padding: 11px 0; text-decoration: none; color: inherit;
}
.pi-price--link:hover { border-bottom-color: var(--accent); }
.pi-price__link:hover .pi-price__label { color: var(--accent-text); }

/* 🚨 TWO columns, fixed, not `auto-fit`. Operator, 2026-09-19: "Who pierces
   - make 2x2". `auto-fit` with a 290px minimum floors to FOUR columns at
   this page's 1200px body, so four piercers rendered as one thin 4-across
   strip of 56px avatars rather than a 2x2 of faces. A count-based rule is
   what "2x2" means, and it holds whatever the body width is. */
.pi-piercers { list-style: none; margin: 0; padding: 0; display: grid; gap: 14px; grid-template-columns: 1fr; }
@media (min-width: 700px) { .pi-piercers { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.pi-piercer__link {
  display: flex; align-items: center; gap: 14px; text-decoration: none; color: var(--text);
  padding: 12px; border: 1px solid var(--rule); background: var(--bg-mid);
}
.pi-piercer__link:hover { border-color: var(--accent); }
/* 🚨 112px since 2026-09-19 (operator: "have artist image larger"), up from
   56px. Two columns is what pays for it: at four-across there was no room.
   The rescue rule near the end of this file restates the height at higher
   specificity because `[data-section] img` reverts it — change one, change
   both. */
.pi-piercer__photo {
  width: 112px; height: 112px; flex: none; object-fit: cover;
  border-radius: var(--ti-chip-radius, 999px); background: var(--bg); border: 1px solid var(--rule);
}
.pi-piercer__body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.pi-piercer__name { font-family: var(--font-display); font-size: 19px; line-height: 1.15; }
.pi-piercer__bio { font-size: 13.5px; color: var(--muted, var(--text)); line-height: 1.45; }

.pi-grid { display: grid; gap: 8px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (min-width: 620px) { .pi-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
.pi-tile { position: relative; display: block; padding-top: 100%; overflow: hidden; background: var(--bg-mid); }
.pi-tile img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }

.pi-actions { display: flex; flex-wrap: wrap; gap: 12px; }
.pi-btn {
  display: inline-flex; align-items: center; padding: 11px 22px;
  font-family: var(--font-body); font-weight: var(--btn-weight, 600);
  letter-spacing: var(--btn-tracking, 0.04em); text-transform: var(--btn-transform, none);
  border-radius: var(--btn-radius, 4px); text-decoration: none; border: 1px solid transparent;
}
.pi-btn--primary { background: var(--accent); color: var(--accent-contrast, var(--text)); }
.pi-btn--ghost { background: transparent; color: var(--text); border-color: var(--rule); }
.pi-btn--ghost:hover { border-color: var(--accent); color: var(--accent-text); }

/* --- Video CTA + byline place (2026-09-07) -------------------------------
   The video is a way to book, so the ask is pinned for the whole run rather
   than living only on the end card, which is on screen ~3 s a loop. Sits
   opposite the Pause button; the black scrim reasoning from the teaser block
   applies here too — this floats over photographs. */
.ti-teaser__cta {
  position: absolute;
  left: 12px;
  bottom: 12px;
  z-index: 5;
  padding: 8px 14px;
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--accent-text);
  background: #ffffff;
  border-radius: var(--ti-chip-radius, 999px);
}
.ti-teaser__cta:hover,
.ti-teaser__cta:focus-visible {
  background: var(--accent);
  color: var(--text);
  outline: 2px solid #ffffff;
  outline-offset: 2px;
}
/* The teaser's own progress ticks stop short of it. */
.ti-teaser .ti-teaser__cta ~ .ti-teaser__progress,
.ti-teaser__progress { left: 12px; right: 96px; }
.ti-teaser[data-state] .ti-teaser__progress { bottom: 52px; }
/* The reel spot and the board phone both position their CTA the same way. */
.ti-video,
.roster-board__phone { position: relative; }

.ti-byline__place { color: var(--muted, var(--text)); }

/* --- Image detail: pager at both ends (2026-09-07) -----------------------
   The bottom pager is a full scroll away on a phone, where the image fills
   the screen — you had to pass the description and the keyword chips to
   reach "next". The same nav is rendered above the image too.

   The top copy is aria-hidden with its links out of the tab order, so a
   screen reader and a keyboard user meet ONE set of gallery controls rather
   than the same two links twice per image. Swipe and arrow keys are
   unaffected. */
.ti-pager-top { margin: 0 0 12px; }
.ti-pager--compact {
  /* Back to the base THREE columns as of 2026-09-18: the compact copy still
     drops the position label, but it now carries the Contact button in that
     middle cell (operator: "The Contact should be a button centered between
     the back and next"), so the cell is no longer empty. It was `1fr 1fr`
     for exactly as long as there was nothing to put there. */
  grid-template-columns: 1fr auto 1fr;
  margin: 0;
  padding: 0 0 10px;
  border-bottom: 1px solid var(--rule);
}

/* --- Mobile tap targets (2026-09-07 audit) -------------------------------
   Measured on a real 390x844 iPhone viewport across twelve pages. Horizontal
   overflow was clean everywhere; the failures were all TARGET SIZE — WCAG
   2.5.8 asks 24x24 CSS px, Apple 44pt, Google 48dp, and these sat at 12-21px.

   Every fix grows the TARGET, never the type: the small type in a utility
   strip or a breadcrumb is a deliberate typographic choice, and enlarging it
   would be answering a different question. Negative margins keep the
   surrounding layout the height it already was. */
@media (max-width: 768px) {
  .ti-crumbs a,
  .ti-crumbs__current {
    display: inline-flex;
    align-items: center;
    min-height: 40px;
    margin-top: -12px;
    margin-bottom: -12px;
  }
  .ti-pglink {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
  .ti-secthead__link,
  .ti-styles__more,
  .ti-shop__phone a,
  .ti-byline a {
    display: inline-flex;
    align-items: center;
    min-height: 40px;
  }
  /* The pill row and the peer row are already gap-separated, so a taller
     target does not collide with its neighbour. */
  .ti-pill,
  .ti-peers__link { min-height: 40px; }
  /* Style chips measured 29px; the CTA pinned on the video measured 19px,
     which is the page's primary action sitting under half the WCAG floor. */
  .ti-chip {
    display: inline-flex;
    align-items: center;
    min-height: 40px;
  }
  .ti-teaser__cta {
    min-height: 40px;
    display: inline-flex;
    align-items: center;
  }
}

/* =====================================================================
   🚨 SQUARE AVATARS: restore the heights sections.css takes away.

   `sections.css` ends with an unlayered rescue —
   `[data-section] img { height: revert-layer }` — added 2026-07-26 so a
   tenant logo would stop rendering at full intrinsic size in the nav. It
   works for the rules it was written for, which live in `@layer section` in
   that same file.

   This file is UNLAYERED, so `revert-layer` does not roll back to anything
   declared here: it rolls all the way back to Tailwind preflight's
   `img { height: auto }`. And `[data-section] img` is (0,1,1), which beats a
   bare `.ti-peers__photo` (0,1,0) whatever the sheet order. Every square
   avatar declared in this file therefore lost its height inside a themed
   section. Measured live on an artist page before this block:

     .ti-header__thumb  128 x 128 -> 128 x 199   (and border-radius: 50%,
                                                  so the artist's face
                                                  rendered as an ellipse)
     .ti-teaser__face    72 x 72  ->  72 x 151
     .ti-peers__photo    44 x 44  ->  44 x 2     (a two-pixel sliver — which
                                                  is why "Also at ..." read
                                                  as a bare list of names)

   Restated at (0,2,1) so they win outright rather than depending on which
   stylesheet the theme renderer injects second. No `:is()` — `safari 12`.

   🚨 THESE SIX VALUES ARE A SECOND COPY. Change one, change its twin above.
   ===================================================================== */
[data-section] img.ti-header__thumb { height: var(--ti-header-thumb, 128px); }
[data-section] img.ti-lineup__photo { height: 46px; }
[data-section] img.ti-peers__photo { height: 44px; }
/* 🚨 A LOGO is not a face. `cover` is right for a photograph of a person and
   destroys a wordmark: the roster board's phone reel feeds the studio's
   400x113 logo into a 72x72 card, and `cover` cropped the middle out of it —
   "ART IMMORTAL TATTOO & PIERCING" rendered as "IMMO / TTO / PIERCI"
   (operator screenshot, 2026-09-14). The caller says which it is via
   TeaserCard.photoFit; `cover` stays the default, so nothing that passes a
   real photograph moves.

   The inset keeps a contained wordmark off the circular crop's own edge, and
   the ground is the page's, not a colour, so a logo with a transparent
   background does not sit on a void. */
.ti-teaser__face[data-fit="contain"],
.ti-teaser__card-photo[data-fit="contain"] {
  object-fit: contain;
  padding: 6px;
  background: var(--bg);
}
[data-section] img.ti-teaser__face { height: 72px; }
[data-section] img.ti-teaser__card-photo { height: 72px; }
[data-section] img.pi-piercer__photo { height: 112px; }
/* 🚨 The reel's own photograph is the SEVENTH casualty, and the most visible
   one: `.ti-teaser__img` declares `height: 100%` so `object-fit: cover` fills
   the fixed-ratio stage, and inside a themed section `[data-section] img`
   reverted it to `auto`. The photo then sized to its own aspect and the rest
   of the stage stayed the component's black — measured on an artist page
   2026-09-18, a 420x747 portrait stage carrying a 420x630 image, i.e. a
   116px black band under every cut (operator screenshot the same day:
   the vertical slideshow's lower half black).
   This was already found ONCE and fixed in one place only — the roster
   board's phone reel, `sections.css` ~13978, whose comment records the
   identical measurement (348x478 in a 348x619 phone). That rule is (0,4,0)
   and still wins where it applies; this restores every OTHER placement. */
[data-section] img.ti-teaser__img { height: 100%; }

/* =====================================================================
   The walk-in sign, pinned over a LOCATION reel.

   Operator, 2026-09-19: *"you could use that same tilted walk-in sign during
   the slideshows for the locations not the artists cause they might be
   booked."*

   🚨 The exclusion is a truth condition, not a layout choice, and it is
   enforced in ReelEmbed rather than here: an artist reel never renders this
   markup at all. A shop's door being open says nothing about whether the
   artist on screen has a chair free, so "walk-ins welcome" over one person's
   portfolio is an offer they have not made. See ReelEmbed's `sign` prop.

   🚨 Z-INDEX 6, above everything inside the teaser. `.ti-teaser` sets no
   z-index or transform of its own, so it establishes NO stacking context and
   its descendants compete in the same context as this sibling — and they run
   up to 5 (the end card's CTA). A lower value here puts the sign behind a
   slide rather than over it, which is the same class of bug the mask's own
   comment above records.

   🚨 TOP, never bottom. The caption, the beat and the bottom mask all live in
   the lower half of the stage; the top-right corner is the only region of a
   reel frame that is reliably empty across all four orientations.

   Tokens only, and `transform` + plain rgba only — this renders under all 17
   theme palettes, and `color-mix()` is banned by the `safari 12` kiosk pin.
   ===================================================================== */
.ti-reel-signed { position: relative; }
.ti-reel-signed__sign {
  position: absolute;
  top: 14px;
  right: 14px;
  z-index: 6;
  /* The wrapper is a hit-test hole so it can never eat a click meant for the
     slide beneath it; the sign itself takes its clicks back below. */
  pointer-events: none;
  /* 🚨 A SIGN, NOT A BANNER — and the cap lives HERE, not on the sign.
     Measured at 1280 before it: an unbounded sign carrying a full policy
     sentence rendered 512px wide, 47% of the reel, and 98% of the frame at
     390px — it covered the work it was hung on. A percentage on the sign
     itself would resolve against this shrink-to-fit wrapper rather than the
     frame; this element's containing block is `.ti-reel-signed`, which has a
     definite width, so the cap means what it reads as. A long sentence now
     wraps to two or three short lines in the corner. */
  max-width: 34%;
}
.ti-reel-signed__sign .ti-reel-sign {
  pointer-events: auto;
  display: inline-block;
  padding: 0.3rem 0.55rem;
  /* 🚨 THE TWO LITERALS HERE ARE ONE DECISION, and it is deliberate: this
     sits over a PHOTOGRAPH, not a theme surface, so NO token describes what
     is behind it and no palette can be trusted to contrast with it. Measured
     in Chromium against the live page: the first version used
     `var(--accent-text)` on a 0.42 scrim and came out at **1.77:1 over a
     bright cut** (3.91:1 over a dark one) — a fail at any size, and invisible
     from the HTML, which is why this was only caught by rendering it.
     Light ink on a heavy scrim is the one pair that holds over ANY
     photograph: 9.1:1 over a white cut, ~19:1 over a black one. Plain rgba
     and #fff because color-mix() is banned by the `safari 12` kiosk pin.
     The LIT state below goes back to tokens — it is an opaque fill, so the
     theme controls both sides of it and the photo stops mattering. */
  border: 3px solid #fff;
  color: #fff;
  background: rgba(0, 0, 0, 0.72);
  font-family: var(--font-display);
  font-size: 0.68rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  text-decoration: none;
  /* 🚨 POSITIVE (operator, 2026-09-21 — it used to be -4deg). It was briefly
     paired with an opposite tilt on the shop page's heading; that heading was
     removed the same day because it sat 136px left of the stage it titled, so
     this sign now leans on its own. That is fine and deliberate: it is a
     placard pinned to a photograph and reads as one without a partner. Do not
     straighten it back looking for the pair. */
  transform: rotate(4deg);
  max-width: 100%;
  box-sizing: border-box;
}
/* Lit: the face fills with the accent, as the hero stamp's does.
   🚨 NO GLOW. The hero's lit state adds an ambient shadow in its own accent
   hue, which it can do because art-immortal.css knows that hue; here the rule
   is shared across all 17 palettes, so any literal glow is right in one of
   them and wrong in the rest. The fill, the border and the phrase carry the
   state — and the phrase is what carries it where colour cannot. */
.ti-reel-signed__sign .ti-reel-sign[data-open="open"] {
  background: var(--accent);
  color: var(--bg);
  border-color: var(--accent);
}
/* 🚨 The phrase, not the glow. A palette that paints nothing for data-open
   must still SAY the shop is open, or the state is carried by colour alone
   and reaches nobody on a screen reader or a monochrome display — the rule
   sections.css states for the hero stamp, restated here because this sign is
   a different element on a different sheet. */
.ti-reel-signed__sign .ti-reel-sign__open { font-weight: 700; }
/* A phone gives the stage roughly a third less width; the sign holds its
   position but stops crowding the first cut. */
@media (max-width: 600px) {
  /* 34% of a 342px phone stage is 116px — narrower than the words — so the
     cap is relaxed where the frame is narrow and the type shrinks instead. */
  .ti-reel-signed__sign { top: 10px; right: 10px; max-width: 62%; }
  .ti-reel-signed__sign .ti-reel-sign { font-size: 0.6rem; border-width: 2px; padding: 0.24rem 0.44rem; }
}
