/* =========================================================================
   Upline Bones — the rules layer
   =========================================================================

   This stylesheet contains NOTHING about how a site looks. It contains only
   what every Upline-approved theme must do to work correctly with the Upline
   for WooCommerce plugin. A child theme supplies the design; this supplies the
   behaviour the design is not allowed to break.

   BEFORE ADDING ANYTHING HERE — WHICH SURFACE ARE YOU ADDING TO?
   -------------------------------------------------------------------------
   The chassis offers skins two API surfaces, and they are not equally used.
   Measured across all three skins that exist: the `ub-*` CLASS surface is
   consumed heavily and everywhere — `ub-reveal-items` 24 times in one skin,
   `ub-cap-4` 13, the whole `ub-archive__*` set, `ub-defer`, `ub-inline-menu`,
   `ub-scroll-x` — while the `ub-*` TOKEN namespace is read exactly once, by
   one skin, for one value. Two skins reference no `ub` token at all.

   So: **declare classes generously, mint tokens reluctantly.** A class costs a
   name and buys a place a skin can hook; a token costs a name, a default, a
   contract row, and a reverse check that cannot be evaluated from inside this
   artifact — three of them were removed in 2.3.12 having never been read by
   anything. Gate 18's classifier watches the surface that carries the load;
   gate 14's reverse direction watches the one that mostly does not.

   Which is not a licence to name loosely. Every `ub-*` class here is one of
   three things and the gate makes you say which: **structural** (emitted and
   styled here), **slot** (emitted here, styled by the skin — naming a place
   without decorating it is the design), or **opt-in** (styled here, applied by
   the skin).

   Everything here is either:
     - a hard rule from Build Spec §4 expressed in CSS, or
     - a motion or loading primitive that a child theme opts into by class, or
     - a structural fact about how WordPress assembles the document.

   Every colour in this file comes from a token. There is not one literal
   colour value below, on purpose: if a rule here needs a colour that no token
   provides, that is a missing token and it gets requested from the plugin
   (Build Spec §4.4) rather than invented here.

   WHAT IS NOT HERE ANY MORE — read this before adding a plugin selector
   ---------------------------------------------------------------------
   Versions up to v7 carried a large section headed "PLUGIN SURFACE HOOKS —
   VERIFIED": 49 rules, 14.8KB, styling .upline-menu, .upline-facet,
   .upline-mega, .upline-tile and their internal descendants by hand. It was
   63% of this file.

   It was correct when it was written, because the plugin then ignored theme
   tokens: the rail read none at all, and the token block was scoped to three
   classes, so a theme setting accent got it on whatever it thought to check
   and silently not on the rest. Hand-styling was the only route.

   upline-facets 1.13.0 fixed that. Every storefront surface is now inside the
   token scope, and a test in the plugin fails if its CSS and docs/theme-tokens.md
   stop agreeing in either direction. So that section became three problems at
   once: redundant, because tokens do the work; fragile, because half its
   selectors reached below the top-level classes the plugin guarantees, into
   __element and --modifier names it says will move; and actively harmful,
   because where a theme rule and a token both apply the theme wins, so a
   shop's own token setting does nothing — which is the exact failure 1.13.0
   was released to fix.

   It is deleted. Do not reinstate any of it. Per the plugin's own contract,
   needing a plugin selector is a bug report, not a theme technique: if a
   surface cannot be reached through the fifteen tokens, that is a missing
   token, and it gets raised against the plugin.

   THE ONE THING THE TOKENS CANNOT DO, AND WHERE IT IS HANDLED — 2.3.14
   -------------------------------------------------------------------
   There is exactly one class of problem the "request a token" route cannot
   answer, because it is not the plugin failing to reach a surface: it is THIS
   THEME reaching into the plugin's surface without meaning to. WordPress emits
   styles.elements.link as a rule matching every <a> on the page, the plugin's
   panels included, and inheritance loses to any matching declaration. So the
   plugin sets `color: panelFg` on its drawer and expects its links to inherit
   it, and this theme's link colour overrides all of them. Measured on a bare
   Bones install: drawer ground rgb(26,26,26), drawer links rgb(26,26,26),
   contrast 1.00, thirty-seven invisible links.

   The plugin cannot defend against this. Its rules live in @layer upline and
   global styles are unlayered, so a `color: inherit` on its side loses no
   matter how it is written. That is the @layer inversion in its hazard
   direction and it makes the carve-out ours by construction.

   The carve-out lives in theme.json under styles.blocks["upline-facets/menu"],
   NOT in this file — it names a public block, WordPress emits the selector, and
   no plugin internal is mentioned, so the rules below still hold exactly. It
   declares currentColor, never a palette value: the ink belongs to the surface
   and moves with it. Gate 20 asserts it, in both directions.

   Rules this file is held to:
     - No selectors into plugin markup at all.
     - No !important. Not one.
     - Never hide disabled filter options, never restyle the mobile toggle into
       a JS-dependent control, never change chip height.
     - Wide content scrolls in its own container, never the page.
   ========================================================================= */


/* -------------------------------------------------------------------------
   1. Foundations
   ------------------------------------------------------------------------- */

/* Hard rule §4.5, applied globally: the page never scrolls sideways. Anything
   that would force it scrolls inside itself instead. */
html,
body {
	overflow-x: clip;
}

/* The named escape hatch for wide content — spec tables, wide grids, anything
   the P2 two-column spec table brings. Applied as a block className. */
.ub-scroll-x {
	overflow-x: auto;
	overscroll-behavior-x: contain;
}

/* One focus treatment for every theme built on Bones, tokenised so a child
   changes it without writing CSS. Plugin surfaces inherit this too. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
	outline: var(--wp--custom--ub--focus-ring, 2px) solid var(--wp--custom--ub--focus-color, currentColor);
	outline-offset: var(--wp--custom--ub--focus-offset, 2px);
	border-radius: var(--wp--custom--upline--radius, 0px);
}

/* Minimum tap target, from the plugin's own `tap` token so a child and the
   plugin cannot disagree about it. Scoped with :where() so it contributes no
   specificity and a design can size a control up freely — it only ever raises
   a floor. Lowering `tap` is an accessibility regression; the plugin says so
   and this is where that statement lands in the theme. */
:where(.wp-block-button__link, .wp-element-button, button, [type="submit"], [type="button"]) {
	min-block-size: var(--wp--custom--upline--tap, 44px);
}

/* The same floor, applied to pagination, because a page number is a control a
   thumb has to hit and core ships it as a bare link with no minimum at all.
   `core/query-pagination` renders `.page-numbers` on the archive and
   `.wp-block-query-pagination-*` on the wrapper links; both spellings are
   core's own published output, so both are named. Only the pill shape, the
   type and the active treatment are look — the floor is not, which is why it
   sits here and not in a skin. Flex rather than min-block-size alone: a link
   with a 44px box and a 17px line still puts its glyph at the top. */
:where(.wp-block-query-pagination) :where(a, span).page-numbers,
:where(.wp-block-query-pagination-previous, .wp-block-query-pagination-next) {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	min-block-size: var(--wp--custom--upline--tap, 44px);
	min-inline-size: var(--wp--custom--upline--tap, 44px);
	padding-inline: var(--wp--custom--upline--gap-sm, 0.5rem);
}

/* SCOPE THE FLOOR BY ROLE, NOT BY ELEMENT WORD — 2.3.14.
   ---------------------------------------------------------------------------
   The floor above names `button`, `[type=submit]` and the block-button classes:
   the elements a control is USUALLY built from. The requirement is about what a
   thumb has to hit, and those are not the same set. A render leg on an unskinned
   chassis measured three controls escaping a floor that reads as universal:

     .wc-block-customer-account__link        36px   Woo renders it as an <a>
     .ub-header__secondary a                 17px   a nav link in a paragraph
     .ub-header__logo a                      19px   the site title, which is a
                                                    link to the home page

   Every vendor surface that is a control-in-an-anchor is outside a floor named
   by element word, and no static check sees it — it took a browser. The account
   control is Woo's published class, which §6 makes fair game; the other two are
   the chassis's own header slots.

   WHY THESE ARE NAMED ONE BY ONE RATHER THAN AS `.ub-header a`.
   A descendant floor on the header would reach the PLUGIN's menu links, which
   sit inside it, and give its mega-panel entries a 44px box the plugin never
   asked for — changing the geometry of a surface this theme does not own,
   through a selector gate 6 cannot see because it names no plugin class. Reach
   and assumption get scoped together, so each slot is named for itself.

   A NOTE FOR THE SKIN THAT WANTS A THIN UTILITY LINE. This raises 2e's
   secondary row from 17px to the tap floor, which is a visible change and the
   correct one. A skin that wants the line to read thinner earns it with type,
   colour and padding inside a 44px box — not by lowering the floor, which is
   the one direction the token is not allowed to move.

   TWO RULES, BECAUSE THE ROLES DIFFER IN THE INLINE AXIS. Written first as one
   rule carrying only `min-block-size`, and measured on the unskinned chassis it
   was wrong twice over: the icon control went to 60px tall and stayed 36px
   WIDE. Woo ships that control `box-sizing: content-box` with 8px of padding,
   so a 44px block floor becomes a 60px box — and an icon control's target is
   square, so a block-axis floor alone leaves the thumb a 36px-wide strip. An
   icon control takes both axes and a border box; a text link takes the block
   floor and lets its own text decide the inline extent. Measured after the
   split: 44x44, 44, 44, with the brand row unchanged at 72px and no overflow
   at 390. The remedy needed the file, not the inventory. */
:where(.wc-block-customer-account__link, .wc-block-customer-account__toggle,
	.wc-block-mini-cart__button) {
	box-sizing: border-box;
	display: inline-flex;
	align-items: center;
	justify-content: center;
	min-block-size: var(--wp--custom--upline--tap, 44px);
	min-inline-size: var(--wp--custom--upline--tap, 44px);
}

/* THE BAG JOINED THIS LIST LATE, AND THE REASON IS THE LESSON — 2.3.16.
   2.3.14 gave the account control both axes because it was measured at 36x36.
   The bag was measured at the same time, read 33x44, and was not fixed: the
   generic `button` floor above had already given it its HEIGHT, so it did not
   look like a member of the class — it looked like a control that was nearly
   right. An icon control's target is SQUARE, and a floor that lands on one axis
   leaves a 33px-wide strip for a thumb while reporting a compliant 44.
   Measured on 2a's render leg, unskinned: `wc-block-mini-cart__button` 33x44,
   the only header control under the floor once the instrument was scoped to
   what a shopper can touch. Half a floor reads as a floor, which is why it
   survived a release that was looking directly at it. */

:where(.ub-header__secondary) :where(a),
:where(.ub-header__logo) :where(a) {
	display: inline-flex;
	align-items: center;
	min-block-size: var(--wp--custom--upline--tap, 44px);
}

/* ONE BRAND, NOT TWO — 2.3.31.
   ------------------------------------------------------------------------
   MEASURED ON BIKECO at plugin 1.54.0 / Bones 2.3.30 / Ridgeline 2.15.7: the
   logo slot renders `.wp-block-site-logo` (the image, at x922, 150 wide, with
   the wordmark inside the artwork) AND `.wp-block-site-title` ("bike co." at
   x1092) side by side. The shop's name appears twice in its own header.

   IT IS THE CHASSIS'S, AND IT IS IN ALL SIX PATTERNS. Every
   `header-*.php` places `wp:site-logo` and `wp:site-title` in this slot, and
   has since they were written. What changed is only who met it: Ridgeline
   2.14.0 rendered its OWN header part with `wp:site-logo` alone, so the pair
   never co-existed; 2.15.0 handed the header to the switcher and inherited a
   case the pattern never handled. Nothing was lost by the skin — a latent
   chassis defect became reachable. Which also means it is waiting for every
   other skin that adopts the switcher, Slope included.

   WHY A RULE AND NOT SIX PATTERN EDITS. Both blocks are legitimate: a shop
   with no logo needs the title, and a shop whose logo artwork contains the
   wordmark does not. The pattern cannot know which it has; only the rendered
   DOM can. So the condition belongs in CSS, keyed on core's own published
   classes — the same dependency class as `wp-block-template-part` at section
   4, and stable for the same reason.

   `:has(.wp-block-site-logo img)` rather than `:has(.wp-block-site-logo)`: the
   block still emits its wrapper in some contexts when no logo is set, and a
   wrapper is not a logo. The image is the thing that makes the title
   redundant, so the image is what the test asks for.

   DEGRADATION IS THE POINT: no logo image, no match, title stays. A shop that
   removes its logo gets its wordmark back with no theme change.

   `:where()` on the title keeps this at (0,1,0)+0 so a skin that wants both —
   a logo mark beside a typeset name is a real design — overrides it with one
   plain declaration rather than fighting the chassis. */
.ub-header__logo:has(.wp-block-site-logo img) :where(.wp-block-site-title) {
	display: none;
}


/* -------------------------------------------------------------------------
   2. Motion primitives
   -------------------------------------------------------------------------
   Bones owns the MECHANISM; a child theme owns the taste — durations and
   easings come from tokens, and a child opts a section in by adding a class.

   Two hard constraints shape all of it:
     - Build Spec §6.6 forbids layout shift on menu open, so nothing here
       animates a property that triggers layout. Opacity and transform only.
     - The theme ships no JavaScript, so nothing may depend on a script to
       become visible. The failure mode of an IntersectionObserver reveal
       (script never runs, content stays at opacity 0, page looks empty)
       cannot occur here: the only reveal rules live inside a support query
       whose animation END STATE is the normal rendered state.
   ------------------------------------------------------------------------- */

@media (prefers-reduced-motion: no-preference) {

	/* Scroll-driven reveal. Where animation-timeline is unsupported nothing is
	   applied at all and content is simply visible. */
	@supports (animation-timeline: view()) {
		.ub-reveal {
			animation: ub-rise linear both;
			animation-timeline: view();
			animation-range: entry 0% cover 24%;
		}

		/* Staggered children — cards, tiles, columns. Each child runs its own
		   view() timeline, so the stagger comes from their real positions
		   rather than hardcoded delays that go wrong at a different viewport
		   height. */
		.ub-reveal-items > * {
			animation: ub-rise linear both;
			animation-timeline: view();
			animation-range: entry 0% cover 20%;
		}
	}

	@keyframes ub-rise {
		from {
			opacity: 0;
			transform: translate3d(0, 14px, 0);
		}
		to {
			opacity: 1;
			transform: none;
		}
	}

	/* Panels that appear from display:none — mega menu, search dropdown. A
	   child opts in; the duration and easing come from tokens. */
	@keyframes ub-panel-in {
		from {
			opacity: 0;
			transform: translate3d(0, -6px, 0);
		}
		to {
			opacity: 1;
			transform: none;
		}
	}

	.ub-panel-in {
		animation: ub-panel-in var(--wp--custom--ub--motion-panel, 380ms) both;
	}
}

/* Reduced motion: neutralise every kind. The scroll-driven ones need their
   TIMELINE removed, not just a short duration, or they keep binding scroll
   position to opacity — which is why this is more than the usual blanket
   rule. */
@media (prefers-reduced-motion: reduce) {
	*,
	*::before,
	*::after {
		animation-duration: 0.001ms;
		animation-iteration-count: 1;
		transition-duration: 0.001ms;
		scroll-behavior: auto;
	}

	.ub-reveal,
	.ub-reveal-items > *,
	.ub-panel-in {
		animation: none;
		animation-timeline: auto;
		opacity: 1;
		transform: none;
	}
}


/* -------------------------------------------------------------------------
   2a. The collapse primitive
   -------------------------------------------------------------------------
   A header that shrinks as the page scrolls, with no JavaScript. Every header
   option in the wireframe set except one needs this, so it is the chassis's,
   and a skin supplies only the two heights.

   THE PREVIOUS ATTEMPT NEVER RAN, AND THE REASON IS THE WHOLE DESIGN.
   -------------------------------------------------------------------------
   Ridgeline shipped `rl-header-compact` and it sat `pending: true` with
   `currentTime: null` through every release — registered, correct in every
   part anyone thought to check, and touching nothing. Reproduced in a browser
   rather than reasoned about:

     an ancestor with `overflow: hidden`      → pending: true, never runs
     an ancestor with `overflow-x: hidden`    → pending: true, never runs
     the same, with `scroll(root block)`      → collapses, 72px -> 44px

   `animation-timeline: scroll()` is shorthand for `scroll(nearest block)`, and
   *nearest* is resolved against the ancestor chain. Any ancestor that is a
   scroll container captures the timeline — **including one that only scrolls
   on the inline axis** — and if it cannot scroll in the block axis the
   timeline is INACTIVE, which presents as an animation that exists, validates,
   and does nothing.

   A theme does not own its ancestor chain. WordPress wraps template parts,
   plugins wrap their blocks, Woo wraps its own, and any of them may set
   `overflow-x: hidden` for reasons that have nothing to do with this. So
   `nearest` is a rule whose correctness depends on a value it does not own —
   scar 9, in the one place where being wrong produces no symptom at all.

   **Name the source: `scroll(root block)`.** `root` is the document, which no
   ancestor can take away. (`overflow-x: clip` does NOT capture it — clip makes
   no scroll container — which is why Bones' own global clip in section 1 was
   never the culprit, and why guessing would have pointed at the wrong file.)

   THE HEIGHT IS RESERVED, so the collapse cannot feed back on itself. The
   outer box holds `--ub-collapse-from` for the document's layout and the inner
   bar is what shrinks. A sticky box that changed the page's own height while a
   scroll timeline read that same height would be a rule driven by a value it
   is simultaneously changing.

   A skin opts in by class and sets the two heights. Below the browser floor
   nothing animates and the header stays at its resting height, which is a
   perfectly good header: this is an enhancement, not a dependency. */

.ub-collapse {
	--ub-collapse-from: 72px;
	--ub-collapse-to: 44px;
	--ub-collapse-range: 120px;

	min-block-size: var(--ub-collapse-from);
}

.ub-collapse__bar {
	display: flex;
	align-items: center;
	min-block-size: var(--ub-collapse-from);
}

/* THE BAR'S CHILD MUST FILL IT — 2.3.27, and this is a defect fix with range's
   numbers behind it.
   ------------------------------------------------------------------------
   MEASURED ON RANGE (2.3.26, `header-drawer`, 1440x900): the bar's child — the
   `alignwide is-content-justification-space-between` row with `max-width: 1280px`
   — came out 521px wide at x460-981, so `space-between` had no free space to
   distribute and all five slots packed into the middle of a 1440px bar: nav 460,
   logo 572, search 717, secondary 771, actions 872. The frame for 2a is
   edge-to-edge.
   REPRODUCED HERE before anything was changed, in Chromium against this
   stylesheet: child 422px at x509-931, nav 509 / logo 573 / search 721 /
   secondary 790 / actions 889. Different text metrics, same geometry — and
   `search` landing at 721 against range's 717 is the tell that it is the same
   shape rather than a lookalike.

   THE CAUSE IS TWO OWNERS OF ONE BOX. All three patterns that carry
   `ub-collapse__bar` declare `layout: constrained` on that same group — drawer on
   its brand row, two-row and utility-line on their strips. WordPress implements a
   constrained layout by giving the CHILDREN a max-width and auto inline margins,
   and it never sets `display` on the container: the arrangement only works while
   the container is a block and the child is a block-level box filling it. The rule
   above makes it a FLEX container, so the child stops filling and sizes to its
   content. Nothing about either declaration is wrong on its own; they are
   incompatible, and the chassis one wins silently.

   FOUR CANDIDATES MEASURED, not reasoned:
     as shipped        child  422px x509  · vertical 25/25 centred
     flex-grow: 1      child 1280px x80   · vertical 25/25 centred   <- shipped
     display: grid     child  422px x509  · NO CHANGE
     display: block    child 1280px x80   · vertical 0/50, content pinned to top

   `display: grid` was the candidate this bench expected to work — grid items
   stretch on the inline axis by default — and it changed nothing, because WP's
   constrained layout also gives the child `margin-inline: auto`, and auto margins
   beat `justify-items: stretch` exactly as they beat `flex-grow`. Measured, not
   assumed, and worth recording because it is a plausible "simplification".

   `display: block` is the other obvious fix and it trades one defect for another:
   the width comes right and the vertical centring goes, content pinned to the top
   of a 72px bar. That is what the flex was for.

   So: keep the flex, let the one child grow. `min-inline-size: 0` was tested
   alongside and adds nothing measurable at 360 or 1440, so it is not here — and
   neither width overflows (scrollWidth == innerWidth at both). The three patterns
   with no collapse bar are untouched by this rule, confirmed in the same harness:
   push's child measures 1280px at x80 before and after.

   HONEST LIMIT ON ALL OF THE ABOVE: the harness emulates what WordPress emits for
   `constrained` and `flex` layouts; it is not WordPress. It reproduces range's
   geometry, which is what makes it evidence — but the fix is owed a read on range
   like any other. */
.ub-collapse__bar > * {
	flex-grow: 1;
}

@media (prefers-reduced-motion: no-preference) {
	@supports (animation-timeline: scroll(root block)) {
		.ub-collapse__bar {
			animation: ub-collapse linear both;
			animation-timeline: scroll(root block);
			animation-range: 0 var(--ub-collapse-range);
		}

		/* THE KEYFRAME ANIMATED A MINIMUM THAT WAS NOT BINDING — fixed 2.3.36,
		   and the defect was this file's, in two rules that never met.
		   ----------------------------------------------------------------
		   `min-block-size: var(--ub-collapse-to)` was the whole of the keyframe,
		   and on the drawer it moved nothing at all. The bar's height is
		   `max(min-block-size, content + padding-block)`, and the padding comes
		   from `.ub-header__brand-row` — 1,500 lines below, under a different
		   class, on THE SAME ELEMENT: the drawer pattern writes
		   `class="ub-header__brand-row ub-collapse__bar"`. Two rules of this
		   stylesheet, each correct alone, neither aware of the other. The second
		   instance of the same shape as the `display: flex` against
		   `layout: constrained` conflict documented above — same element, same
		   class pair, and that one took a four-candidate measurement to find too.

		   MEASURED ON BIKECO AT 1440, plugin 1.56.0, Ridgeline's tokens, by
		   applying each end state statically so the MECHANISM is tested and the
		   timeline is not in the way:

		     resting                        bar 72   min-block 64   pad 14/14
		     min-block-size -> --to ONLY    bar 72   <- TODAY'S KEYFRAME
		     + padding-block: 0             bar 52   <- --to, exactly
		     --to 44 + padding-block: 0     bar 44   <- 2a's spec, exactly

		   Row two is the defect with nothing left to argue about: the keyframe's
		   own end state, forced, does not move the bar one pixel. So this was
		   never a trigger problem — it would not move with a live timeline
		   either, which is why it is fixed ahead of that question being settled.

		   WHY `padding-block: 0` AND NOT A NEW TOKEN. The minimum already says
		   what the condensed height is; it simply was not reachable. Zero the
		   padding and `max(--to, content)` is the height, so the existing
		   declaration becomes binding WITHOUT ITS VALUE CHANGING, and a skin's
		   two heights keep their meaning. 44 is the floor this mechanism can
		   reach, because the content is tap-sized controls — which is exactly
		   what 2a's spec asks for in words, "burger · logo mark · search · bag
		   only", and row four is that number measured rather than argued.

		   `align-items: center` on the bar keeps the row centred as the box
		   shrinks, so nothing jumps; the outer `.ub-collapse` still holds its own
		   minimum, so the page height is not driven by the thing the timeline
		   reads.

		   LIMIT, STATED: `two-row` and `utility-line` carry this bar on
		   `.ub-header__strip`, which no stylesheet gives padding, so their
		   minimum was already binding and this line is a no-op for them. Read
		   from the stylesheets, not measured — neither option is flipped on a
		   bench right now. `bottom-bar`, `push` and `sidebar` carry neither
		   class and are untouched; their own docblocks say so. */
		@keyframes ub-collapse {
			to {
				padding-block: 0;
				min-block-size: var(--ub-collapse-to);
			}
		}
	}
}


/* -------------------------------------------------------------------------
   3. Deferred rendering
   -------------------------------------------------------------------------
   WordPress core already handles image loading attributes: it marks the first
   in-viewport image fetchpriority="high", lazy-loads the rest, and adds
   decoding="async". Re-implementing that would fight core and would put logic
   in a theme that is meant to be dumb.

   What a theme owns is deferring the RENDER of off-screen sections, not just
   their images. content-visibility skips layout and paint until the user
   reaches the section; contain-intrinsic-size supplies a placeholder height so
   the scrollbar stays honest and in-page anchors still land.

   The other half — reserving space so a lazily-loaded image never shifts the
   page — is the child theme's job, because ratios are a design decision.
   ------------------------------------------------------------------------- */

.ub-defer {
	content-visibility: auto;
	contain-intrinsic-size: auto 640px;
}


/* -------------------------------------------------------------------------
   4. Page structure
   -------------------------------------------------------------------------
   Rules about how WordPress assembles the document, not about how any one
   theme looks. A child theme should never have to restate these.
   ------------------------------------------------------------------------- */

/* WordPress separates every top-level block with the root blockGap, and that
   includes the seam between the header template part and <main>. On a theme
   whose header is a solid band and whose first section is a full-bleed hero,
   the gap renders as a strip of page background wedged between them — a white
   bar under a black header, which reads as a bug rather than as spacing.

   Specificity note: WordPress emits its rule as
   `:where(.wp-site-blocks) > * + *`, which contributes NOTHING to specificity,
   so a single class here wins regardless of stylesheet order. */
.wp-site-blocks > main {
	margin-block-start: 0;
}

/* Sticky offset. Logged-in WordPress renders a fixed admin bar over the top of
   the viewport, so anything stuck to `top: 0` slides underneath it. Shops see
   0; we see 32 (or 46 on the narrow breakpoint, where the admin bar is taller
   and stops being fixed at all). Children position against this token instead
   of guessing. */
:root {
	--ub-sticky-top: 0px;
}

body.admin-bar {
	--ub-sticky-top: 32px;
}

@media screen and (max-width: 782px) {
	body.admin-bar {
		/* Below 782px the admin bar is position:absolute, not fixed — it
		   scrolls away with the page, so nothing needs to clear it. */
		--ub-sticky-top: 0px;
	}
}

/* The sticky header, and why it has to live HERE rather than in the skin.
   ------------------------------------------------------------------------
   A `wp:template-part` with `tagName:"header"` renders its own wrapper —
   `<header class="wp-block-template-part">` — around whatever the part file
   contains. A skin that also opens a group with `tagName:"header"` therefore
   produces two nested headers, and `position: sticky` on the INNER one does
   nothing at all: a sticky box can only travel inside its parent's padding
   box, and that parent is exactly as tall as it is. Zero travel. It scrolls
   away looking exactly like a sticky rule that was never written.

   Measured on bikeco before this rule existed: at scrollY 600 the header's
   top was -568px, i.e. gone. Wrapper height 181, inner height 181.

   So the sticky belongs on the wrapper, which only the parent can reach —
   a child theme cannot add a class to a template-part call inside a Bones
   template it inherits. `wp-block-template-part` is core-generated and
   core-published, the same class of dependency as Woo's `wc-block-*`, and
   unlike a plugin's `__element` names it does not move between releases.

   A design that does not want a sticky header sets `position: static` on
   this selector in its own stylesheet. Theme CSS is unlayered, so one plain
   declaration wins — no `!important`, no specificity games. */
.wp-site-blocks > header.wp-block-template-part {
	position: sticky;
	top: var(--ub-sticky-top, 0px);
	z-index: 40;
}

/* AND THE BAR HAS TO PAINT, or sticky is worse than nothing — 2.3.29.
   ------------------------------------------------------------------------
   MEASURED ON RANGE, bare Bones, all widths (591 and 390, same chain at 1440):
   every box from `.ub-header__logo` up through `.ub-header__brand-row`,
   `.ub-collapse`, `.ub-header--drawer` to the template part itself computes
   `background-color: rgba(0, 0, 0, 0)`. The rule above pins that transparent
   stack over the page at z-40, so scrolled content renders straight THROUGH the
   header. A header that scrolls away is a missing feature; a header you can
   read the page through is a broken one.

   THE ASSUMPTION THAT PUT IT THERE IS WRITTEN IN THIS FILE, at the checkout rule
   in section 9: "on every other page the wrapper itself paints nothing and the
   skin's own header group inside it supplies the opaque surface." That sentence
   was true of the only skin anyone had rendered — Ridgeline paints `.rl-header`
   from `base`, which is why bikeco never showed this in six releases of looking
   at it. So the chassis has been shipping a sticky bar with no surface since
   2.3.5 and a skin was quietly covering for it. A defect only visible when a
   particular child theme is ABSENT is one that can live in a chassis for a long
   time, because the chassis is almost never what anyone looks at.

   IT PAINTS `.ub-header`, NOT THE STICKY WRAPPER, and the distinction is the
   whole care in this fix. Painting the wrapper would be the obvious move and it
   would reverse a ruling this file already made: section 9 rejected exactly that
   ("the other candidate fix was to paint this wrapper opaque"), because Woo's
   checkout renders its OWN `checkout-header` part into the same wrapper, and
   painting it would mean the chassis dressing a part it neither authors nor
   paints. `.ub-header` is on the outermost group of all six header patterns —
   markup this chassis does own — so this fix leaves that ruling standing rather
   than quietly overturning it, and checkout is untouched because Woo's part
   carries no `.ub-header`.

   `surface` RATHER THAN `base`, chosen and not defaulted. Bare Bones paints the
   page ground from `base`, so a base-painted bar would be opaque and correct and
   look like no bar at all — and the chassis's standing instruction to itself is
   that inheriting it unchanged should look UNFINISHED, not broken. An opaque bar
   you can see is the minimum functional state. A skin overrides in one plain
   declaration, which is what Ridgeline already does.

   `Canvas` as the fallback, not a hex: it is the UA's own page-background system
   colour, so it is opaque and it follows the user's light/dark preference. A
   hardcoded `#fff` would be a guess about a palette this file does not own.

   WHAT THIS DOES NOT REACH: a skin that renders its own header element and
   paints nothing. The invariant is the sticky wrapper is transparent by design,
   so whatever fills it must paint — Bones' own patterns now do, and a skin's own
   header element is the skin's to paint. Gate 26 asserts it per artifact rather
   than leaving it as this paragraph. */
.ub-header {
	background-color: var(--wp--preset--color--surface, Canvas);
}


/* -------------------------------------------------------------------------
   5. Grid column ceiling
   -------------------------------------------------------------------------
   THE PROBLEM THIS EXISTS FOR
   ---------------------------
   A block grid set to "auto" columns emits
   `repeat(auto-fill, minmax(<min>, 1fr))`. That formula has no upper bound: it
   fits as many tracks as the container allows. At a 1480px wide measure a
   200px minimum yields SEVEN columns, so a four-item row fills the first four
   and leaves three empty tracks — which looks exactly like a section that has
   been left-aligned inside a container that is too wide. It is not an
   alignment bug and no amount of justify/margin fixes it; the row genuinely
   has seven cells.

   The fix is to raise the track FLOOR to one-Nth of the row. Once a track can
   never be narrower than a quarter of the available width, a fifth track can
   never fit, and `auto-fill` still steps down to 3, 2 and 1 as the container
   narrows. No media queries, no column counts to keep in sync.

   NOTE: for the plugin's OWN product grids this is not the right lever — the
   collection-grid column count is a plugin setting and should be set there.
   These classes are for grids the theme renders itself.

   Specificity note: WordPress's own rule is a single class
   (`.wp-container-core-group-is-layout-XXXX`) in an INLINE stylesheet that
   lands after the theme's linked file. A single class here would tie and lose.
   Every selector below is therefore at least two classes. This is written down
   so the next person does not rediscover it. */
/* TWO CORRECTIONS, Bones 2.3.8 — the ceiling was capping at N-1, everywhere.
   -------------------------------------------------------------------------
   1. THE GAP THE FORMULA SUBTRACTS MUST BE THE GAP THE GRID USES.

   `--ub-cap-gap` read `--wp--custom--upline--gap` — the theme's SPACING token
   — while the grid is laid out with `--wp--style--block-gap`, which is a
   different value. On bikeco: token **10px**, real gap **20px**. The floor is
   therefore computed one gap-size too generous, and a floor that is too large
   is a column that cannot fit:

       floor    (1480 - 3 x 10) / 4 = 362.5px
       available(1480 - 3 x 20) / 4 = 355px
       four tracks cannot fit, by 7.5px — `auto-fill` steps down to three

   Reported as "the confidence bar renders three columns with two entries
   stacked in the first", and that is exactly what a four-item row does in a
   three-track grid. But the defect is not that bar: **every capped grid on
   every site is one column short whenever the block gap exceeds the spacing
   token**, silently, because a grid that quietly caps at 3 looks like a
   deliberate 3-column grid. The formula subtracted a number it did not own.

   2. `auto-fill` -> `auto-fit`, because correcting (1) exposed what (1) had
   been accidentally hiding.

   The over-large floor was ALSO suppressing empty tracks: a three-item row in
   a cap-4 grid rendered three tracks, which looks right. Fix the gap alone and
   that row gains a fourth, empty track — the "section left-aligned inside a
   container that is too wide" look this section's own problem statement exists
   to kill. `auto-fit` collapses tracks with nothing in them, so a row shorter
   than its cap fills the width instead of trailing a dead column.

   Measured on the front page, both grids, before and after: the three-item row
   holds 480/480/480 with the fourth track collapsed to 0px and its right edge
   flush, and the four-item bar reads 355/355/355/355 on one row. Below roughly
   660px `--ub-cap-min` dominates both formulas identically, so the mobile
   stacking is arithmetically untouched — which is the constraint Ben set. */
.ub-cap-4.is-layout-grid,
.ub-cap-6.is-layout-grid {
	--ub-cap-gap: var(--wp--style--block-gap, var(--wp--custom--upline--gap, 16px));
}

.ub-cap-4.is-layout-grid {
	grid-template-columns: repeat(
		auto-fit,
		minmax(max(var(--ub-cap-min, 150px), calc((100% - 3 * var(--ub-cap-gap)) / 4)), 1fr)
	);
}

.ub-cap-6.is-layout-grid {
	grid-template-columns: repeat(
		auto-fit,
		minmax(max(var(--ub-cap-min, 120px), calc((100% - 5 * var(--ub-cap-gap)) / 6)), 1fr)
	);
}


/* -------------------------------------------------------------------------
   6. Archive shell
   -------------------------------------------------------------------------
   The two-column rail-and-grid arrangement that templates/archive-product.html
   uses. It is here rather than in a child because the DEFAULT archive lives
   here — but it is a default, not a decree.

   A child theme that wants a different shape (filters in a drawer only,
   filters across the top, no rail at all) replaces archive-product.html
   wholesale and never uses these classes. That is expected, not a workaround.

   The flex values are the "holy grail" arrangement: the rail holds its column
   width, the results take everything else, and the pair wraps to one column
   when there is no longer room for both — with no media query and no
   breakpoint to keep in sync with the design.
   ------------------------------------------------------------------------- */

/* The archive head sits at the wide measure but its CONTENT reads at the
   content measure, hard left. `layout: constrained` would do the capping and
   then centre the box in the wide container — which renders as a title
   floating in the middle of the page, not as a hero. `layout: default` plus a
   cap here keeps the left edge aligned with the rail below it. */
.ub-archive__head > * {
	max-inline-size: var(--wp--style--global--content-size, 720px);
	margin-inline: 0;
}

.ub-archive__body {
	display: flex;
	flex-wrap: wrap;
	gap: var(--wp--custom--upline--gap, 16px);
}

.ub-archive__rail {
	flex: 1 1 var(--wp--custom--upline--column, 16rem);
}

/* 28rem, not 20rem, and the reason is the stacking point rather than the
   width. These two flex bases decide when the pair wraps: it happens once
   rail-basis + results-basis + gap exceeds the container. At 20rem that lands
   near 590px, which leaves a window where the rail still holds its full column
   and the grid is squeezed to ~330px — one cramped product per row. At 28rem
   the pair stacks below roughly 720px instead, so the grid never renders
   narrower than about two columns before it goes full width. Measured, not
   guessed: side by side at 900 and 700, stacked at 390. */
.ub-archive__results {
	flex: 999 1 28rem;
	min-inline-size: 0;
}

/* The results toolbar — count on one side, sorting on the other.
   archive-product.html has emitted `ub-archive__toolbar` since the shell
   shipped and this stylesheet declared `__head`, `__body`, `__rail` and
   `__results` and not this one, so the class arrived in every skin's DOM with
   nothing behind it. Two independent skins each discovered they had to style
   it and each did, privately, and on both sites it looked deliberate.

   That is an undeclared token wearing markup's clothes: no runtime signal, and
   every skin pays the same tax without knowing anyone else did. Gate 18 now
   asserts the chassis's own classes in both directions so the next one cannot
   ship silently.

   The template already asks for flex with space-between, so the arrangement
   is the block's; what belongs here is the pair's behaviour when the row runs
   out of room, which is the part a template attribute cannot express. */
.ub-archive__toolbar {
	gap: var(--wp--custom--upline--gap-sm, 0.5rem);
}

.ub-archive__toolbar > * {
	margin-block: 0;
}

/* Sticky rail on viewports tall and wide enough to benefit. Offset by the
   admin-bar token so it never slides under the toolbar. */
@media (min-width: 782px) {
	.ub-archive__rail > * {
		position: sticky;
		top: calc(var(--ub-sticky-top, 0px) + var(--wp--custom--upline--gap, 16px));
	}
}


/* -------------------------------------------------------------------------
   6b. Product page shell
   -------------------------------------------------------------------------
   The media/buy pair that templates/single-product.html uses, on the same
   terms as the archive shell: a replaceable DEFAULT, not a decree. A child
   that wants a different PDP replaces the template and never sees these.

   Bones shipped no `single-product.html` at all until 2.3.12, so an unskinned
   Bones fell through to Woo's own fallback on the one page a shopper decides
   to buy on — `source: plugin` in the template registry, which reads like
   ownership and is actually a gap.

   The gallery column takes the larger share and holds it; the buy column
   never drops below a readable measure. `columns` stacks on its own below
   781px, which is core's behaviour and not a number this file owns. */
.ub-pdp__media {
	flex-basis: 55%;
	min-inline-size: 0;
}

.ub-pdp__buy {
	flex-basis: 45%;
	min-inline-size: 0;
}

/* Woo's classic gallery floats its images and caps the wrapper; both are
   Woo's layout opinion arriving on a block page. Released here rather than in
   a skin because every skin meets it. Qualified with the element Woo itself
   targets, because `.woocommerce div.product div.images` measures (0,3,2) and
   a three-class override loses on element count — the rule would sit in the
   file and nothing would move. */
.ub-pdp__media .woocommerce div.images,
.ub-pdp__media div.woocommerce-product-gallery {
	inline-size: 100%;
	max-inline-size: none;
	float: none;
}

.ub-pdp__detail {
	min-inline-size: 0;
}


/* -------------------------------------------------------------------------
   7. Form controls
   -------------------------------------------------------------------------
   Browsers ship a look for inputs, selects and buttons, and neither WordPress
   nor a block theme overrides it. So a bare <input> renders as OS chrome in
   the middle of a designed page — which is what the Upline search block and
   every Woo field currently do.

   This was briefly mistaken for a missing plugin token. It is not: no token
   would help, because the problem is that nothing in the stack normalises form
   elements at all. The fix belongs here, at element level, where it reaches
   every control on the site — the plugin's, Woo's and the theme's — without
   naming a single plugin class.

   Everything below inherits: font from the theme, colour from currentColor,
   border from the rule token, corners from the radius token. Bones states no
   opinion about how a field should look, only that it should look like the
   theme it is sitting in.
   ------------------------------------------------------------------------- */

:where(input, select, textarea) {
	font: inherit;
	color: inherit;
	background-color: transparent;
	border: 1px solid var(--wp--custom--upline--rule, currentColor);
	border-radius: var(--wp--custom--upline--radius, 0px);
	padding-block: var(--wp--custom--upline--gap-sm, 8px);
	padding-inline: var(--wp--custom--upline--gap, 16px);
	min-block-size: var(--wp--custom--upline--tap, 44px);
}

/* Checkboxes and radios are the exception: the rules above would blow them up
   to a 44px box. They keep their native rendering and only take the accent. */
:where(input[type="checkbox"], input[type="radio"]) {
	min-block-size: auto;
	padding: 0;
	border: 0;
	border-radius: 0;
	accent-color: var(--wp--custom--upline--accent, currentColor);
}

:where(input[type="search"]) {
	-webkit-appearance: none;
	appearance: none;
}

:where(select) {
	-webkit-appearance: none;
	appearance: none;
	/* Room for the caret drawn below, on whichever side the language runs. */
	padding-inline-end: calc(var(--wp--custom--upline--gap, 16px) * 2);
	background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
		linear-gradient(135deg, currentColor 50%, transparent 50%);
	background-position: right 1.05em top 55%, right 0.75em top 55%;
	background-size: 0.3em 0.3em, 0.3em 0.3em;
	background-repeat: no-repeat;
}

:where(input, select, textarea)::placeholder {
	color: var(--wp--custom--upline--muted, currentColor);
	opacity: 1;
}

/* `details > summary` before any skin can draw its own +/−.
   The marker is drawn two different ways depending on the engine — a
   `list-item` display with a `::marker`, or WebKit's own
   `::-webkit-details-marker` pseudo — so suppressing one leaves the other
   rendering beside whatever the skin draws. All three declarations are the same
   mechanism, and every skin needs all three before it can start: Ridgeline and
   Slope each rediscovered it privately. `cursor` belongs here for the same
   reason the tap floor does — a disclosure that does not look clickable is a
   control the shopper does not find.

   :where() keeps it at zero specificity: this clears the ground, it does not
   decide what stands on it. */
:where(details > summary) {
	list-style: none;
	cursor: pointer;
}

:where(details > summary)::-webkit-details-marker {
	display: none;
}

:where(details > summary)::marker {
	content: "";
}

/* A <button> that is not a block button — Woo's steppers, the plugin's search
   submit, anything a plugin renders raw. It inherits type and loses the OS
   chrome; a child theme decides what it looks like beyond that.

   THE EXCLUSIONS GO INSIDE THE :where(), and that is the whole rule.
   `:where(button):not(.a):not(.b)` reads as zero-specificity and is not:
   :where() contributes zero, but each :not() sitting OUTSIDE it contributes its
   argument's weight, so that spelling measures (0,2,0) — enough to beat the
   single-class rule a skin would write, which is the opposite of a floor. It
   shipped that way and a skin's `.sl-actions button { border: 0 }` at (0,1,1)
   lost to it: the border above stayed, and the header pills drew a second box
   inside the first while this comment promised the child decided the look.
   Inside the :where() the whole selector measures (0,0,0). Measured, not
   reasoned: any rule here documented as zero-specificity gets counted. */
:where(button:not(.wp-element-button):not(.wp-block-button__link)) {
	font: inherit;
	color: inherit;
	background-color: transparent;
	border: 1px solid var(--wp--custom--upline--rule, currentColor);
	border-radius: var(--wp--custom--upline--radius, 0px);
	cursor: pointer;
}


/* -------------------------------------------------------------------------
   8. WooCommerce chrome
   -------------------------------------------------------------------------
   WHY THIS IS ALLOWED WHEN SECTION 4 WAS NOT

   Section 4 was deleted because it reached into upline-facets' internals, and
   that plugin's contract says everything below its top-level classes is
   private and will move. WooCommerce is the opposite case: `wc-block-*` is the
   surface Woo publishes for themes to style, it is stable across releases, and
   Woo's own block themes style it. Targeting it is the supported route, and
   there is no token alternative — Woo reads none of ours.

   The standard this exists to meet: Woo is the functional backend, not the
   front end. None of its default look may reach a customer. So this section
   REMOVES Woo's opinions and re-points them at theme tokens. It adds no design
   of its own — a child theme still decides how any of it looks.
   ------------------------------------------------------------------------- */

/* Woo hardcodes a blue focus ring and its own radius on form fields inside
   cart and checkout. Both are re-pointed at the theme.

   BOTH SELECT SPELLINGS ARE NAMED ON PURPOSE — 2.3.12.
   ------------------------------------------------------------------------
   Woo 11.1 renders the checkout select as `wc-blocks-components-select__select`
   — `wc-blocks-`, with an s. The older `wc-block-` spelling is what this rule
   carried, and against Woo 11.1 it matched nothing: the rule sat in the file
   reading as correct, the country field kept Woo's radius, and there was no
   runtime signal of any kind, because a selector that matches nothing does not
   complain. Reported from a second skin's live install, not found here.

   "Published and stable" means Woo documents the name, not that the name is
   immortal across majors. The new spelling is added BESIDE the old rather than
   replacing it: installs on both majors exist, and an unmatched selector costs
   nothing, so keeping both is strictly cheaper than being wrong about which
   one a given shop runs.

   THE CLASS IS DOUBLED ON PURPOSE — 2.3.3.
   ------------------------------------------------------------------------
   Written singly, this rule lost. Measured on bikeco's live checkout: the
   email field computed `border-radius: 4px` with this stylesheet enabled AND
   with it disabled — identical — which is what a rule that never applies looks
   like. Woo ships a more specific declaration of its own.

   The token was NOT the problem, and that was worth proving rather than
   assuming, because "the var doesn't reach wc-block subtrees" would have been a
   contract finding against the plugin. It reaches: a probe element appended
   inside the field's own parent, with `border-radius: var(--wp--custom--upline--radius, 99px)`,
   computed **2px** — the sentinel never fired. Token fine, specificity losing.

   Repeating the class is specificity WITHOUT structural coupling. The three
   candidates measured — `.wp-block-woocommerce-checkout` ancestor,
   `.wc-block-checkout` ancestor, and this — all reached 2px. The ancestors were
   rejected for two reasons: each names markup above the field that Woo can
   restructure, which is the descendant-chain fragility this project gates
   against; and `.wc-block-checkout` is checkout-only, so cart's coupon field
   would have kept Woo's radius. Doubling the class Woo publishes couples to
   nothing but the name it already promises to keep. */
.wc-block-components-text-input.wc-block-components-text-input input[type="text"],
.wc-block-components-text-input.wc-block-components-text-input input[type="email"],
.wc-block-components-text-input.wc-block-components-text-input input[type="tel"],
.wc-block-components-text-input.wc-block-components-text-input input[type="number"],
.wc-block-components-select__select.wc-block-components-select__select,
.wc-blocks-components-select__select.wc-blocks-components-select__select,
.wc-block-components-textarea.wc-block-components-textarea {
	border-color: var(--wp--custom--upline--rule, currentColor);
	border-radius: var(--wp--custom--upline--radius, 0px);
	background-color: transparent;
	color: inherit;
}

/* Woo's checkout uses its OWN `checkout-header` template part, not the theme's
   — a deliberate distraction-free checkout, the same pattern Shopify uses. That
   is design, not omission, so it is STYLED here and never replaced: swapping the
   full site header back in would hand the shopper a nav bar at the exact moment
   they are deciding to pay.

   Structure only, from tokens. A child theme still decides how the wordmark
   itself looks; this only stops the part rendering as unstyled text on nothing.
   Scoped by Woo's published `woocommerce-checkout` body class so it cannot
   reach any other header.

   `position: static` un-sticks it, and that is a mechanic rather than a taste
   (Bones 2.3.5). Section 4 makes `.wp-site-blocks > header.wp-block-template-part`
   sticky, and that rule is written for the header the SKIN renders: on every
   other page the wrapper itself paints nothing and the skin's own header group
   inside it supplies the opaque surface. Checkout renders Woo's `checkout-header`
   part instead, so the skin's header group is not in the document at all — which
   leaves a sticky, transparent, z-40 box with nothing painting it. Body content
   scrolls straight through it. Reported from the live walk as "transparent, or
   renders below the body content"; one mechanism, both descriptions.

   The other candidate fix was to paint this wrapper opaque and keep it sticky.
   Rejected: it would hand distraction-free checkout a persistent bar nobody
   asked for, and it would mean Bones imposing sticky behaviour on a part it
   neither authors nor paints — the same class of error as walking into markup
   you do not own. A skin that wants a sticky checkout header declares
   `position: sticky` and its own background in its own unlayered CSS, the
   symmetric escape hatch to the one documented at the section 4 rule.

   `:not(.woocommerce-order-received)` is the scope correction of Bones 2.3.6,
   and it is the law above caught one register out. Woo puts
   `woocommerce-checkout` on the body for the WHOLE checkout FLOW — the form
   step AND the order-received endpoint — but only the form step renders the
   `checkout-header` part. MEASURED on the live confirmation page: it renders
   the full site header (`.rl-header` inside this wrapper) and the full site
   footer, so 2.3.3's padding and hairline had been landing on the SKIN's
   header there, and 2.3.5's `position: static` went on to un-stick it.

   A body class marks the FLOW; this rule needed the TEMPLATE. Both classes are
   Woo-published and stable, so the discriminator costs one token and couples
   to nothing structural. */
.woocommerce-checkout:not(.woocommerce-order-received) > .wp-site-blocks > header.wp-block-template-part,
.woocommerce-checkout:not(.woocommerce-order-received) header.wp-block-template-part {
	position: static;
	padding-block: var(--wp--preset--spacing--50, 1rem);
	padding-inline: var(--wp--preset--spacing--50, 1rem);
	border-block-end: 1px solid var(--wp--custom--upline--rule, currentColor);
	margin-block-end: var(--wp--preset--spacing--70, 2rem);
}

/* The checkout trust strip (templates/page-checkout.html).
   ------------------------------------------------------------------------
   Woo's own page-checkout ships header + main and NO footer, so a shopper on
   the page where they decide whether this shop is real has no return path, no
   policy, no way to ask a question. That is the one unambiguous gap on the
   page; the rest of Woo's minimal checkout is deliberate and is left alone.

   The three items are TEXT, not links, and that is not an oversight. A returns
   page and a contact page do not exist on every shop — on bikeco they do not
   exist yet — and a trust strip whose links 404 is the anti-trust strip. Build
   spec: never fake a function. Each item carries its own slot class so the day
   a shop has the page, the text becomes a link in a named container and nothing
   about the layout moves. */
.ub-checkout-trust {
	border-block-start: 1px solid var(--wp--custom--upline--rule, currentColor);
	color: var(--wp--custom--upline--muted, inherit);
	text-align: center;
}

/* Product tile image alignment — Bones 2.3.7.
   ------------------------------------------------------------------------
   The image box is a design ratio the skin sets; the image inside it is
   whatever ratio Woo's Product Image block emits — and it emits that ratio as
   an INLINE STYLE on the tag (`aspect-ratio:1/1`), which no stylesheet rule
   can reach without `!important`. So a skin cannot make the two agree by
   styling alone, and the mismatch has to be absorbed rather than argued with.

   Measured on bikeco before this rule: a 4:5 box at 373px holding a 1:1 image
   at 298px, the image flush to the TOP, with 75px of dead ground below every
   card in the grid. Centred it reads 37px above, 37px below.

   This is the chassis's business and not the skin's: the numbers come from the
   skin's ratio and the block's attribute, but "a shorter image sits in the
   middle of its box, not jammed against the top" holds for every ratio and
   every design that will ever inherit from here. Nothing about it is a look.

   The anchor is the flex item, not the image — Woo wraps the image in a link —
   so it takes the full inline size and the image centres inside it. */
.wc-block-components-product-image {
	display: flex;
	align-items: center;
	justify-content: center;
}

.wc-block-components-product-image > a {
	display: block;
	inline-size: 100%;
}

/* Woo's notices ship a green/blue/red palette and an icon column. The colour
   comes back to the theme; the structure is left alone because it carries
   meaning. */
.wc-block-components-notice-banner {
	border-radius: var(--wp--custom--upline--radius, 0px);
	border-color: var(--wp--custom--upline--rule, currentColor);
	color: inherit;
}

/* The quantity stepper and its buttons — OS chrome by default. */
.wc-block-components-quantity-selector {
	border-color: var(--wp--custom--upline--rule, currentColor);
	border-radius: var(--wp--custom--upline--radius, 0px);
}

.wc-block-components-quantity-selector__button {
	color: inherit;
	background: transparent;
	border: 0;
	min-inline-size: var(--wp--custom--upline--tap, 44px);
}

/* Woo's own button classes, so a cart or checkout CTA is the theme's button
   rather than Woo's. Colour is deliberately not set here — that is design, and
   it comes from the child through the button element styles. */
.wc-block-components-button:not(.is-link) {
	border-radius: var(--wp--custom--upline--radius, 0px);
	min-block-size: var(--wp--custom--upline--tap, 44px);
}

/* Woo draws its own hairlines in a hardcoded grey. */
.wc-block-components-totals-wrapper,
.wc-block-cart-items,
.wc-block-components-order-summary,
.wc-block-components-shipping-rates-control {
	border-color: var(--wp--custom--upline--rule, currentColor);
}

/* Woo's price strikethrough and sale colours. */
.wc-block-components-product-price__regular,
.wc-block-components-product-metadata__description {
	color: var(--wp--custom--upline--muted, currentColor);
}

/* MY ACCOUNT — classic markup, because Woo ships no block equivalent.
   ------------------------------------------------------------------------
   Every other surface in this section is `wc-block-*`. The account pages are
   not: `woocommerce-MyAccount-navigation` and `woocommerce-orders-table` are
   Woo's CLASSIC published classes, and there is no block version to prefer.
   They are equally documented and equally stable, so they are equally fair
   game — the rule in §6 is "Woo's published classes", not "Woo's newest ones".

   Without this, a shopper who clicks Account leaves the theme entirely: a
   bulleted list of links and a browser-default table, on a page they reached
   from a designed header. Every skin rewrites the same chrome, which is the
   test for chassis; what stays with the skin is the pill shape, the type and
   the active treatment.

   Structure only. No colour beyond the tokens, no spacing scale of its own. */
.woocommerce-MyAccount-navigation ul {
	list-style: none;
	margin: 0;
	padding: 0;
	display: flex;
	flex-wrap: wrap;
	gap: var(--wp--custom--upline--gap-sm, 0.5rem);
}

.woocommerce-MyAccount-navigation a {
	display: inline-flex;
	align-items: center;
	min-block-size: var(--wp--custom--upline--tap, 44px);
	padding-inline: var(--wp--custom--upline--gap-sm, 0.5rem);
	border: 1px solid var(--wp--custom--upline--rule, currentColor);
	border-radius: var(--wp--custom--upline--radius, 0px);
	text-decoration: none;
	color: inherit;
}

/* The orders table is wide by construction — order, date, status, total,
   actions — so it gets the same escape hatch a spec table gets rather than
   pushing the page sideways. §1's `overflow-x: clip` would otherwise clip it
   with no way to reach the actions column.

   QUALIFIED WITH THE ELEMENT WOO ITSELF TARGETS — 2.3.13, and the reason is a
   defect this file shipped in 2.3.12.
   ------------------------------------------------------------------------
   Woo's own table rule is `.woocommerce table.shop_table`, which measures
   (0,2,1). The first version of this rule was
   `.woocommerce-MyAccount-content .woocommerce-orders-table` — (0,2,0) — so it
   LOST ON ELEMENT COUNT, and `border-collapse` and the action link's `display`
   sat in this file reading exactly like intent while changing nothing.
   Measured on a live account page: an identical selector injected as the last
   stylesheet in the document still computed `separate`, which is what sent the
   search past the cascade and into the element count.

   The instrument had to be proved first: the same injected block's `outline`
   DID apply, magenta and visible, while its `border-collapse` did not. One
   declaration landing and another not, from one rule, is not a cascade
   question — it is the property being claimed by a heavier selector elsewhere.

   Change-and-measure on the page, not reasoning about it: at (0,3,1) both
   declarations flip — `separate` -> `collapse`, `block` -> `inline-flex`. */
.woocommerce .woocommerce-MyAccount-content table.woocommerce-orders-table {
	inline-size: 100%;
	border-collapse: collapse;
}

.woocommerce .woocommerce-MyAccount-content table.woocommerce-orders-table th,
.woocommerce .woocommerce-MyAccount-content table.woocommerce-orders-table td {
	border-block-end: 1px solid var(--wp--custom--upline--rule, currentColor);
	padding-block: var(--wp--custom--upline--gap-sm, 0.5rem);
	text-align: start;
}

.woocommerce .woocommerce-MyAccount-content .woocommerce-orders-table__cell-order-actions a.woocommerce-button {
	display: inline-flex;
	align-items: center;
	min-block-size: var(--wp--custom--upline--tap, 44px);
}


/* -------------------------------------------------------------------------
   9. Icon slots
   -------------------------------------------------------------------------
   The chassis does not own an icon LOOK — it owns the invariant that a theme
   has exactly one of them.

   The header's controls come from three different owners: the search toggle
   and the menu toggle are the plugin's, the account link and the mini-cart are
   WooCommerce's. Left alone, each ships its own glyph in its own style, which
   is how one header ends up with a thin-line magnifier, a filled material
   person and a solid cart — measured on bikeco, three families in a row of
   three. No skin can fix that by choosing a nice icon, because the glyphs
   arrive from vendors the skin does not control.

   So Bones publishes SLOTS, not a pack. Each slot is a token carrying a glyph;
   this file masks the vendor's own icon out and paints the slot in its place,
   in `currentColor`, at one size. A skin swaps the whole family by declaring
   the slots in its `theme.json` — one file, one place, all of them together.

   The identity stays the skin's: every theme picks a family that matches its
   design, and two themes are not expected to agree. What is invariant is
   WITHIN a theme — one family, one optical weight, one baseline. The default
   family below exists so an unskinned site is already consistent, not to be
   anybody's design.

   Flat keys, single-dash variables, for the reason the token contract gives:
   a nested `icon: { search }` would emit `--wp--custom--ub--icon--search`,
   which nothing reads, and the slot would silently fall back to the vendor
   glyph — the exact failure this section exists to prevent.

   Slots: search, account, cart, menu, close. Adding a surface means adding a
   slot here AND to the gate's list, so a half-added slot fails loudly instead
   of rendering one vendor icon among five theme ones.
   ------------------------------------------------------------------------- */

.ub-icon,
.upline-menu__toggle .upline-menu__toggle-icon,
.upline-search__toggle .upline-search__toggle-icon,
.wc-block-customer-account__link .wc-block-customer-account__visual,
.wc-block-mini-cart__button .wc-block-mini-cart__quantity-badge::before {
	display: block;
	inline-size: var(--wp--custom--ub--icon-size, 20px);
	block-size: var(--wp--custom--ub--icon-size, 20px);
	background-color: currentColor;
	-webkit-mask-repeat: no-repeat;
	mask-repeat: no-repeat;
	-webkit-mask-position: center;
	mask-position: center;
	-webkit-mask-size: contain;
	mask-size: contain;
}

/* Paint the slot on the vendor's OWN icon box, never on an extra pseudo-element
   beside it.
   ------------------------------------------------------------------------
   2.3.0 added a `::before` next to each vendor glyph and hid the glyph. Two
   things went wrong and both are visible in one screenshot: the vendor `<svg>`
   did not hide, so every control rendered two icons; and even if it had, the
   emptied vendor box would still have occupied its space beside the new one.

   The hiding failed because `display` was being argued at equal specificity.
   `.wc-block-customer-account__link svg` is (0,1,1) and Woo's own rule is too,
   so the later sheet won — and which sheet is later is not something a theme
   should be betting on. Naming the vendor's icon container as well takes it to
   (0,2,1) and settles it by specificity instead of by load order. The same
   correction applies to every selector in this section, which is why they are
   all two classes deep now.

   That is a doctrine point, not a preference. A tie broken by stylesheet order
   is meaning carried by TIMING: Woo can reorder its enqueues in any release
   and the hide flips with no diff on our side and nothing to review. Two
   classes deep puts the outcome in an explicit field of the selector, where it
   can be read and argued with. Prefer the field over the ordering wherever a
   tie is possible.

   The plugin's toggle glyphs are DRAWN, not shipped as files: the menu icon is
   three background bars, and the magnifier is a `1.1em` circle made of
   `border: 2px solid currentColor` with a rotated `::after` for the handle.
   Killing the pseudo-elements alone left that border ring behind — which reads
   as a thin outline box around the search control — and left the span at
   1.1em rather than the family's size. So the border and the radius are
   cleared explicitly, and the size is restated here rather than inherited.

   The general shape, because it will recur on the next drawn glyph: **a glyph
   built from several primitives has to be suppressed as an ANATOMY, not as a
   node.** Enumerate what draws it — background, border, radius, ::before,
   ::after — and clear all of them. Clearing some leaves the remainder reading
   as something it never was: here a lens circle became an apparent button
   outline, and the person judging the header saw a bigger box with a smaller
   glyph in it and had no way to know those were one element.

   That is gate 16's failure one level down. Partial override across slots is
   invisible per element and shows only as mixed families; partial suppression
   within a glyph is invisible as a defect and shows only as a wrong
   impression. Neither errors. Both are found by a human squinting, which is
   the thing the gates exist to stop being the detector. */
.upline-menu__toggle .upline-menu__toggle-icon,
.upline-search__toggle .upline-search__toggle-icon {
	border: 0;
	border-radius: 0;
}

.upline-menu__toggle .upline-menu__toggle-icon::before,
.upline-menu__toggle .upline-menu__toggle-icon::after,
.upline-search__toggle .upline-search__toggle-icon::before,
.upline-search__toggle .upline-search__toggle-icon::after {
	display: none;
}

.upline-menu__toggle .upline-menu__toggle-icon {
	-webkit-mask-image: var(--wp--custom--ub--icon-menu);
	mask-image: var(--wp--custom--ub--icon-menu);
}

/* Open state swaps the same slot for the close glyph, so the control reads as
   one thing that changes rather than two controls in a row. */
.upline-menu__toggle[aria-expanded="true"] .upline-menu__toggle-icon {
	-webkit-mask-image: var(--wp--custom--ub--icon-close);
	mask-image: var(--wp--custom--ub--icon-close);
}

.upline-search__toggle .upline-search__toggle-icon {
	-webkit-mask-image: var(--wp--custom--ub--icon-search);
	mask-image: var(--wp--custom--ub--icon-search);
}

/* The drawer's close glyph — the slot system finally reaching the control a
   phone shopper actually touches, Bones 2.3.9.
   ------------------------------------------------------------------------
   Until plugin 1.26.3 this could not be done as a slot. The glyph was a
   16.5 x 2px painted bar, rotated 45 degrees, with a second arm on a
   pseudo-element — so one theme shipped TWO close glyphs, the header toggle
   wearing `iconClose` and the drawer wearing the plugin's X. Gate 16 could
   not see it: it checks that slots are DECLARED, not that the plugin USES
   them.

   1.26.3 rebuilt it as a born-box, and the anatomy was re-enumerated on the
   served bytes rather than taken from the changelog — the rule this section
   already states, and a habit the previous close-icon reading earned the hard
   way. Measured in the open drawer at 390: **square 16.5 x 16.5, no paint, no
   rotation on the span, both arms on `::before` and `::after`, no border, no
   radius, no shadow.** All four claims held.

   CORRECTED 2026-09-13, and the correction is the more useful half.

   This block originally claimed `transform` was load-bearing: that the plugin
   centred the span with `translate(-8.25px, -8.25px)`, half of 16.5, so
   restating the box at 20px would leave it 1.75px short on each axis. **That
   was wrong, and it was wrong by reading the interface instead of the
   artifact.** The plugin's own declaration is:

       .upline-menu__close-icon { position: absolute; top: 50%; left: 50%;
         width: 1.1em; height: 1.1em; transform: translate(-50%, -50%); }

   `getComputedStyle` resolves a percentage translate against the box, so
   `translate(-50%, -50%)` on a 16.5px span computes to
   `matrix(1, 0, 0, 1, -8.25, -8.25)` — indistinguishable from a hard-coded
   pixel value by reading the computed output. The number was right; the
   AUTHORSHIP was invented. Doctrine rule 4, on this file's own author.

   So the `transform` line below restates the plugin's value verbatim and
   absorbs nothing, and percentage centring re-centres itself when the box
   changes — which is the whole virtue of it. Same for `border` and
   `border-radius`: the plugin declares neither on this span (the magnifier is
   the one with a ring). Three declarations of furniture, kept only until the
   next icon-touching release deletes them, because a restated value becomes a
   conflict the day the other side changes its approach.

   WHAT THIS BLOCK ACTUALLY ABSORBS, checked against the plugin's source
   declarations rather than against computed values:
     - `inline-size`/`block-size`  the plugin's span is `1.1em`; this
                                   normalises it to the icon family's box
     - `background-color`          the span itself is UNPAINTED — only the two
                                   pseudo arms carry `currentColor` — so this
                                   supplies the paint the mask needs
     - the mask slot               the point of the exercise
     - `::before`/`::after: none`  two 2px arms rotated +/-45deg that would
                                   otherwise cross the masked glyph

   The borrowed number everyone went looking for is not here at all. It is on
   the TOGGLE: `.upline-menu__toggle-icon::before { top: -6px }` and
   `::after { top: 6px }`, on a span that is itself `1.1em x 2px` — a bar.
   Section 9's pseudo-suppression plus the box restatement is what absorbs
   that, and no offset arithmetic of ours is involved.

   Verified with the shipped rule text injected into the live drawer: box
   20 x 20 matching the magnifier, 12px clear on all four sides of the 44px
   control, mask applied, both arms suppressed, paint arriving as
   `currentColor` — white, inherited from the drawer.

   THE MEDIA QUERY, AND WHY IT IS 760 AND NOT 782 — Bones 2.3.10.

   2.3.9 shipped these rules unscoped, and that was a defect on the live site:
   the close control EXISTS IN THE DOM AT EVERY WIDTH and only means something
   while the drawer does. Above the drawer's breakpoint the plugin collapses it
   — measured before 2.3.9 as `display: inline`, 0 x 0, no mask — and the slot
   rules handed that collapsed control a 20px box, `currentColor` paint and a
   glyph. Reported from Ben's desktop: the drawer's X floating above the left
   end of the nav, menu closed. A rule about the drawer needs the drawer's
   context.

   The boundary is the PLUGIN'S. CITED, as of the re-stamp, against the
   plugin's published boundary table — "The layout boundaries", tokens doc
   stamped 1.27.1 — which names `max-width: 760px` / `min-width: 761px` for the
   menu surfaces and calls these numbers API. The table's own words: using the
   rail's boundary for the menu's "is wrong by 22px".

   It was originally EXCAVATED here by reading `components.css`, which carried
   exactly two width queries with all eight `__close` rules inside the first.
   The excavation was right and the numbers never moved — but the table's
   phrasing is the point: "excavating a number is not the same as being
   promised one". The promise now carries a bidirectional suite assertion and a
   release marker, so this citation stops aging on releases that do not touch
   boundaries, and the bench that can change the fact owns keeping it true. **Mirroring this file's own 782px would have been
   wrong by 21 pixels:** measured live in a 770px frame, the plugin is in
   desktop layout (the label is an inline 130 x 20 box, not the 44px control)
   while a 782-scoped rule would still have applied — the same floating glyph,
   in a narrower band, where nobody would have looked for it.

   782 is WordPress's breakpoint. 760 is the plugin's. The rule belongs to the
   plugin's drawer, so it takes the plugin's number. */
@media (max-width: 760px) {
	.upline-menu__close .upline-menu__close-icon {
		display: block;
		inline-size: var(--wp--custom--ub--icon-size, 20px);
		block-size: var(--wp--custom--ub--icon-size, 20px);
		background-color: currentColor;
		border: 0;
		border-radius: 0;
		transform: translate(-50%, -50%);
		-webkit-mask-repeat: no-repeat;
		mask-repeat: no-repeat;
		-webkit-mask-position: center;
		mask-position: center;
		-webkit-mask-size: contain;
		mask-size: contain;
		-webkit-mask-image: var(--wp--custom--ub--icon-close);
		mask-image: var(--wp--custom--ub--icon-close);
	}

	.upline-menu__close .upline-menu__close-icon::before,
	.upline-menu__close .upline-menu__close-icon::after {
		display: none;
	}
}

/* THE BACK CONTROL WEARS THE FAMILY TOO — 2.3.31, AND THIS IS THE THIRD
   VERSION OF THIS COMMENT. Read the history before trusting the rule.
   ----------------------------------------------------------------------
   New at plugin 1.54.0 with the sliding panes, designated at the same release
   along with its icon and its label. It joins the close control above in the
   icon system for the reason §24 closed: one theme, one glyph family, on every
   control a shopper touches. Not the IDENTICAL treatment, though the first two
   versions of this said so — the close block carries a pseudo-element
   suppression and this one does not, because here the suppression was measured
   and found inert. Whether the close control's is inert too is an open question
   this cut did not open; it ships working, and testing it is its own item.

   VERSION ONE (2.3.31, SHIPPED) had no suppression and a comment claiming an
   enumeration that never happened: "the anatomy was enumerated before this was
   written ... there is nothing to suppress." Nothing was enumerated. The shape
   was inferred from the close icon and written in the voice of a reading —
   §24's failure, and worse than §24, because there the false explanation sat
   above a CORRECT rule while here it sat above a wrong one and was the sentence
   that would stop the next reader from checking.

   VERSION TWO (this cut, discarded before shipping) added both suppressions and
   said the 2.3.31 defect was "a masked chevron painted over the plugin's own
   glyph" — a doubled glyph — and that the rule sat "inside the plugin's own 760
   boundary ... the panes only exist below it." Both wrong, and wrong in the same
   way as version one: I read the plugin's DECLARATIONS and never read which
   media block they sat in, then wrote "measured" over the gap. The rule was
   inside `@media (max-width: 760px)`.

   WHAT IS ACTUALLY TRUE, measured in Chromium at 390 and 1440 against the
   1.54.2 tree, with the step-3 DOM menu.js builds:

     every plugin rule for this control is inside `@media (min-width: 761px)` —
     the button (`.upline-menu--drawer .upline-menu__back` and the
     `[data-upline-step="3"]` variant), the icon box, and both arms. The
     sliding panes are the DESK drawer. There is no back control below 761.

     at 390  the 760-scoped rule applied and the plugin declared nothing:
             box 20x20 from this theme, mask applied, `::before` content `none`
             — the arms do not even generate. One glyph, and no plugin glyph to
             double.
     at 1440 the 760-scoped rule did not apply at all: box 17.6x17.6 (1.1em at
             16px, the plugin's), mask NOT applied, both arms generating.

   SO THE DEFECT 2.3.31 SHIPPED IS NOT A DOUBLED GLYPH. It is a rule in the
   wrong media range: inert where it applied, absent where the control lives.
   `iconBack` was not honoured at any width, in the release whose comment
   claimed it completed the icon system. Nothing was drawn twice; the theme's
   chevron was never drawn at all.

   Hence 761 here, taking the plugin's number for the plugin's surface, the same
   citation rule the close control's 760 block states above. If the plugin ever
   builds this control below the boundary, this block moves with it.

   NO SUPPRESSION, AND THAT IS A MEASUREMENT RATHER THAN THE ASSUMPTION IT
   LOOKS LIKE — which is the whole reason this comment is long. 2.3.31 also
   shipped no suppression, on a guess. Version two added two, on a different
   guess. Both were then tested at 1440 against both trees, each declaration
   removed in turn and the icon's pixels compared over one window fixed from the
   shipped case's own rect:

     `::before, ::after { display: none }`  removed → PIXEL-IDENTICAL
     `font-size: 0`                         removed → PIXEL-IDENTICAL
     both removed together                          → PIXEL-IDENTICAL
     `mask-image` removed (the control)             → DIFFERS
     the whole block removed (plugin alone)         → DIFFERS

   The control is what makes the identicals mean anything: with no mask the
   render changes, so the mask is shaping the paint and not covering the box in
   a solid square that would swallow the evidence. The plugin's own comment said
   `background: currentColor` plus a mask "replaces the whole glyph and clips
   these away with it" — the arms AND a 1.54.1 tree's `'\2039'` text node are
   both inside this element, so the mask clips both. That claim is correct, and
   is now checked rather than believed.

   So the final rule is 2.3.31's rule body with NOTHING ADDED, in a different
   media range. Everything wrong with 2.3.31 was the range and the comment.

   DO NOT RE-ADD `font-size: 0` in particular. It is not merely redundant: the
   plugin sizes this span `1.1em` at this width, so `font-size: 0` collapses the
   box to 0x0 the moment the explicit `inline-size`/`block-size` above are
   touched — measured, that exact mutation renders no box at all. It buys
   nothing and arms a trap for whoever edits the box next.

   NOT `color: transparent` either, the other tempting one-liner: the mask is
   painted by `background-color: currentColor`, so a transparent colour erases
   this theme's glyph along with the plugin's.

   Two classes deep, like every other icon selector in this file, so specificity
   decides visibly rather than load order deciding silently (§16). */
@media (min-width: 761px) {
	.upline-menu__back .upline-menu__back-icon {
		display: block;
		inline-size: var(--wp--custom--ub--icon-size, 20px);
		block-size: var(--wp--custom--ub--icon-size, 20px);
		background-color: currentColor;
		-webkit-mask-repeat: no-repeat;
		mask-repeat: no-repeat;
		-webkit-mask-position: center;
		mask-position: center;
		-webkit-mask-size: contain;
		mask-size: contain;
		-webkit-mask-image: var(--wp--custom--ub--icon-back);
		mask-image: var(--wp--custom--ub--icon-back);
	}
}

/* THE PHONE BRAND ROW — 2.3.33, AND THE FIRST ARRANGEMENT THIS HEADER HAS EVER
   HAD BELOW THE BOUNDARY.
   ----------------------------------------------------------------------
   There was no media-scoped header arrangement anywhere in this file — not at
   760, not at 782. The slots sat in the pattern's inner `.alignwide` flex group
   and the phone stack was whatever `flex-wrap` produced: measured on bikeco,
   280.9px across five rows at 390, with the shop's own logo appearing FOURTH,
   below the phone number and the secondary links. That was never designed. It
   emerged, and it emerged wrong.

   Declared as a grid rather than tuned as a wrap, because the defect was the
   absence of a decision and a grid is a decision. Measured on bikeco's live
   header against plugin 1.54.5:

     390   280.9px / 5 rows  ->  140.8px / 3 rows
     591   235px   / 5 rows  ->  127px   / 3 rows
     both  zero sibling overlaps, no horizontal overflow

   `max-content` ON THE CONTROL TRACKS, AND THAT IS A SCAR. The first version
   used `auto`, which let the greedy `1fr` logo column squeeze the nav track:
   the toggle went from 58.3 to 44 and its label was CLIPPED, and I read the
   stolen 14.3px as free space in the budget. `max-content` cannot squeeze a
   control below its own content. Proved by giving the toggle a twenty-character
   label in the probe: the track grew to 166.6 and the label survived.

   THE DEGENERATE CASE, MEASURED AND LEFT IN: with a toggle that long the logo
   column goes to ZERO — the mark is squeezed out rather than overflowing, which
   is the invariant below working, not failing. A `minmax(72px, 1fr)` floor was
   tried and is worse: the group grows to 430.6 in a 358px row. There is simply
   no four-item single line at 390 with a long label, and the answer there is a
   two-line brand row, not a track size. It is not built because it is not
   reachable: the plugin fixes the toggle to an icon at every width from 1.54.5,
   so the label cannot grow. IF THAT EVER BECOMES A SHOP SETTING AGAIN, this
   arrangement needs the two-line fallback before that setting ships.

   Only `--drawer` is arranged here. `push` and `bottom-bar` are the other two
   collapse patterns and they are structurally different — push already carries
   `secondary` as a full-width line of its own, bottom-bar has neither actions
   nor search competing in its top bar. Neither is measured on a bench running
   it, so neither is touched. */
@media (max-width: 760px) {
	.ub-header--drawer .ub-header__brand-row > .wp-block-group {
		display: grid;
		align-items: center;
		column-gap: var(--wp--preset--spacing--20);
		grid-template-columns: max-content minmax(0, 1fr) max-content max-content;
		grid-template-areas:
			"nav logo search actions"
			"contact contact secondary secondary";
	}

	.ub-header--drawer .ub-header__nav { grid-area: nav; }
	.ub-header--drawer .ub-header__logo { grid-area: logo; justify-content: center; }
	.ub-header--drawer .ub-header__search { grid-area: search; }
	.ub-header--drawer .ub-header__actions { grid-area: actions; justify-content: flex-end; }

	.ub-header--drawer .ub-header__contact {
		grid-area: contact;
		min-inline-size: 0;
	}

	.ub-header--drawer .ub-header__secondary {
		grid-area: secondary;
		justify-content: flex-end;
		min-inline-size: 0;
	}
}

/* THE UTILITY ROW HELD A TRACK FOR A SLOT THAT WASN'T THERE — 2.3.35, and the
   defect is 2.3.33's, written above.
   ----------------------------------------------------------------------
   The second row is `"contact contact secondary secondary"`, which is right
   when both slots have content and wrong the rest of the time. `:empty` already
   takes an empty `secondary` out — correctly, and that rule is older than this
   one — but a removed GRID ITEM does not release its AREA: columns three and
   four stay in the row holding nothing, and the contact is boxed into the half
   of the line it was assigned. Measured on bikeco at 390, plugin 1.55.2:

     secondary      0 x 0, `:empty`, `display: none`
     contact        x16  w174 of a 358 line   -> 184px of dead track
     contact height 57.8  — wrapping inside 174 with 358 available
     header         140.8

   The plugin emptied the links part, so this is now the DEFAULT state of every
   shop rather than an edge, and it was shipped by the same cut that declared
   the arrangement. Mine.

   `:not(:has(... :not(:empty)))` is one selector for both ways the slot can be
   absent — the element missing entirely, and the element present but empty —
   which is the honest formulation, because the row should fill whenever there
   is nothing to put in those two columns and `:has()` cannot test for absence
   directly. Measured with a CONTROL, because a conditional rule that always
   applies is not conditional and that is not visible from the fixed case alone:

     secondary empty      areas fill    contact 358 x 35.9   header 118.9
     secondary refilled   areas revert  contact 174 x 57.8   header 140.8

   The 21.9px the header gives back is the contact no longer wrapping inside
   half a line. It is a consequence, not the goal, and it is recorded so the
   next reader does not mistake it for a height decision.

   Where `:has()` is unsupported the base rule stands and the shop gets the dead
   track — the degradation this file takes everywhere, not a break. */
@media (max-width: 760px) {
	.ub-header--drawer .ub-header__brand-row > .wp-block-group:not(:has(.ub-header__secondary:not(:empty))) {
		grid-template-areas:
			"nav logo search actions"
			"contact contact contact contact";
	}
}

/* 2d's BAR IS ARRANGED AT PHONE WIDTH — 2.3.38, and it is 2a's grid.
   ----------------------------------------------------------------------
   push had two rules and neither was phone-scoped, so its bar — a flex WRAP row
   of five slots — was left to wrap. Measured at 390: bare Bones 194px over three
   stacked rows; with Ridgeline 219px over four, the logo on a full-width line
   BELOW the contact and the actions full-width under it. Neither broken, both
   tall, neither arranged.

   THE SAME FOUR SLOTS IN THE SAME CONTAINER GET THE SAME ARRANGEMENT. Row one
   here is byte-identical to 2a's — menu 44@16, logo 122@68, search 44@198,
   actions 124@250 — because it is the same 358px row carrying the same
   inventory. That is the point of an option set sharing one inventory: the
   options differ in ARRANGEMENT, and where two arrangements have no reason to
   differ they should not. The logo column is again the `1fr` remainder:
   358 - 44 - 44 - 124 - (3 x 8) = 122, chosen by nobody.

   SIMPLER THAN 2a IN ONE RESPECT. 2d carries `secondary` OUTSIDE the bar as its
   own full-width line, so there is no second-row variant to write and no
   `:not(:has(...))` fallback: row two is unconditionally the contact. 2a needs
   both because its secondary shares the row.

   Row two inherits 2a's open question unchanged — contact wraps with the shop's
   strings at about 18px a line, and the two-line ceiling is the form if Ben ever
   wants one. Left as it ships, for the reason given there: the cost of
   truncating lands on the shop with the longest address.

   Measured: header 194 -> 130 bare, 219 -> 113 with the skin. Not claimed: that
   either looks right. Geometry only; the look is Ben's. */
@media (max-width: 760px) {
	.ub-header--push .ub-header__bar > .wp-block-group {
		display: grid;
		align-items: center;
		column-gap: var(--wp--preset--spacing--20);
		grid-template-columns: max-content minmax(0, 1fr) max-content max-content;
		grid-template-areas:
			"menu logo search actions"
			"contact contact contact contact";
	}

	.ub-header--push .ub-header__menu { grid-area: menu; }
	.ub-header--push .ub-header__logo { grid-area: logo; justify-content: center; }
	.ub-header--push .ub-header__search { grid-area: search; }
	.ub-header--push .ub-header__actions { grid-area: actions; justify-content: flex-end; }

	.ub-header--push .ub-header__contact {
		grid-area: contact;
		min-inline-size: 0;
	}
}

/* 2b's BRAND ROW IS ARRANGED AT PHONE WIDTH — 2.3.39, and 2b is not shaped like
   the others, which is why this rule is shorter than theirs and stops where it
   does.
   ----------------------------------------------------------------------
   2b carries its menu as an inline `mega` on a SECOND STRIP, not as a toggle in
   the main row. Its brand row holds logo, search, contact and actions and no
   menu at all. Both rows sit inside `.ub-collapse`, and the STRIP is the
   `ub-collapse__bar`. The plugin publishes `mega` as `switches_at: 760,
   below: "drawer-panes"`, so at 390 that strip collapses to a single toggle and
   stands 72px tall — 72 being `--ub-collapse-from`, not an arbitrary number.

   Only three rules named any of this before today and all three are
   `@media (min-width: 782px)` desk rules for `.ub-inline-menu`. The phone case
   was never arranged, and left to wrap it measured 222 (bare Bones) and 273
   (with Ridgeline) at 390.

   THE RIDGELINE SHAPE HERE IS THE RULE WORKING, NOT FAILING. `.ub-header__logo
   { flex: 1 0 100%; order: 1 }` is unscoped and lands on every option — it cost
   2f its contact and is queued for scoping in the skin — but
   `.ub-header__brand-row > div` is one of the TWO selectors the skin wrote it
   for, and 2b is the other. Here it does what it was built to do. Worth stating
   so the scoping cut does not get read as a blanket condemnation of a rule that
   is correct in its own two rows.

   WHAT THIS ROW GETS, AND THE ONE NUMBER THAT DIFFERS FROM THE SET. The same
   grid as every other option, minus the menu column it does not have:

       logo 174 @16   search 44 @198   actions 124 @250      contact 358, row two

   Search at 198 and actions at 250 are the set's shared positions. The logo
   column is the `1fr` remainder as everywhere else, and it comes out 174 rather
   than 122 precisely BECAUSE this row has no menu slot — the 52px the menu
   would occupy is the width the mark gains. Both stacks land within 3px of each
   other where today they differ by 51.

   WHAT THIS RULE DELIBERATELY DOES NOT DO: unify the two rows. Dissolving the
   wrappers with `display: contents` and gridding `.ub-collapse` was measured —
   it gives exactly the shared row one and takes the header to 72 — and it was
   REJECTED for this cut because `.ub-collapse__bar` then stops being a box:
   measured `display: contents`, height 0, its `min-block-size` void, while
   `.ub-collapse`'s own 72px clamps the whole header and the contact renders
   past it. That changes WHAT COLLAPSES for this option — today the brand row
   stays and the nav strip condenses; after it there is no strip to condense.
   That is the `ub-collapse` contract, Fault A's scroll leg is still unread, and
   a contract is not something to redefine inside an arrangement cut.

   Measured: 222 -> 192 bare, 273 -> 189 with the skin. Not claimed: that the
   72px one-hamburger strip is worth its space at phone width. That is Ben's
   eye and it is the open question this cut leaves open on purpose. */
@media (max-width: 760px) {
	.ub-header--two-row .ub-header__brand-row > .wp-block-group {
		display: grid;
		align-items: center;
		column-gap: var(--wp--preset--spacing--20);
		grid-template-columns: minmax(0, 1fr) max-content max-content;
		grid-template-areas:
			"logo search actions"
			"contact contact contact";
	}

	.ub-header--two-row .ub-header__logo { grid-area: logo; justify-content: flex-start; }
	.ub-header--two-row .ub-header__search { grid-area: search; }
	.ub-header--two-row .ub-header__actions { grid-area: actions; justify-content: flex-end; }

	.ub-header--two-row .ub-header__contact {
		grid-area: contact;
		min-inline-size: 0;
	}
}

/* 2e's BRAND ROW IS ARRANGED AT PHONE WIDTH — 2.3.40. It is 2b's rule minus a
   row, and the asymmetry it fixes is worth naming.
   ----------------------------------------------------------------------
   2e carries THREE rows inside `.ub-collapse`: a utility line (contact,
   secondary), a brand row (logo, search, actions — no contact, no menu), and the
   `ub-header__strip ub-collapse__bar` holding an inline `dropdown` menu. The
   contract gives `dropdown` the same `switches_at: 760, below: "drawer-panes"`
   as 2b's `mega`, so at 390 the strip collapses to one toggle at 72px, which is
   `--ub-collapse-from` again.

   NOT ONE RULE IN THIS FILE NAMED `utility-line` BEFORE TODAY, at any width. It
   was the least arranged option in the set.

   THE TWO STACKS WERE 109px APART AND ONLY ONE WAS BROKEN. Measured at 390:
   bare Bones 214, with Ridgeline 323. Bare Bones' brand row already fitted one
   line — its height was fine and only its slot positions were off the set's
   (search landed at 178.4 rather than 198, because `space-between` distributed
   the slack). The skin stack is where the damage was: `.ub-header__logo
   { flex: 1 0 100%; order: 1 }` gave the logo a full-width line and the actions
   another, and the brand row went to 180.

   As at 2b, that rule is doing what it was written to do —
   `.ub-header__brand-row > div` is one of its two intended selectors — and 2e
   is simply a row it was not sized for. The scoping cut queued for the skin
   NARROWS it to its own rows; it is not a verdict on it.

   MINUS THE CONTACT ROW, because 2e's contact lives in the utility line above.
   That is the whole difference from 2b's rule, and it is why there is no
   `"contact contact contact"` band here and no row-two variant to declare.

   Measured, shipped sheet, both stacks:

       bare Bones     214 -> 214   brand row 72 -> 72    (positions corrected)
       + Ridgeline    323 -> 215   brand row 180 -> 72

   Within 1px of each other where they differed by 109. The logo column is the
   `1fr` remainder at 174 — the same figure 2b produces, for the same reason: a
   row with no menu slot gives the mark the width the menu would have taken. A
   long address grows the UTILITY row 30 -> 52 and leaves the brand row
   untouched, which is the right place for that cost to land.

   THE STRIP IS STILL 72px FOR ONE HAMBURGER, and this cut does not touch it.
   The unify — `display: contents` on the wrappers with `.ub-collapse` as the
   grid — applies here identically to 2b and carries the identical objection:
   `.ub-collapse__bar` stops being a box and the collapse contract changes. The
   two are one question about one primitive on the only two options that carry a
   nav strip, and they are routed together, after Fault A's scroll leg.

   Not claimed: that three rows at phone width is the right shape for this
   option. That is Ben's eye. */
@media (max-width: 760px) {
	.ub-header--utility-line .ub-header__brand-row > .wp-block-group {
		display: grid;
		align-items: center;
		column-gap: var(--wp--preset--spacing--20);
		grid-template-columns: minmax(0, 1fr) max-content max-content;
		grid-template-areas: "logo search actions";
	}

	.ub-header--utility-line .ub-header__logo { grid-area: logo; justify-content: flex-start; }
	.ub-header--utility-line .ub-header__search { grid-area: search; }
	.ub-header--utility-line .ub-header__actions { grid-area: actions; justify-content: flex-end; }
}

/* 2c's COLUMN BECOMES A BAR AT PHONE WIDTH — 2.3.41, the last of the six.
   ----------------------------------------------------------------------
   2c is the only option that is a COLUMN rather than rows: `.ub-header__column`
   holds logo, search, tree, secondary, contact and actions in flow. What makes
   it a sidebar is the shell, and the shell is `@media (min-width: 761px)` ONLY —

       body.upline-shell--sidebar .wp-site-blocks > header { position: fixed; width: 200px }
       body.upline-shell--sidebar .wp-site-blocks          { padding-inline-start: 200px }

   Below 761 none of that applies: the header is an ordinary block and the column
   simply stacks down the page. Five rules named `sidebar` in this file before
   today — the `@property`, the knob declarations, those two, and the rail
   animation — and not one was phone-scoped. Measured at 390: 286 bare Bones,
   309 with Ridgeline, FIVE full-width rows. The tallest option in the set by a
   wide margin, for the plainest reason in it.

   THIS IS 2a's GRID, FILL VARIANT INCLUDED. 2c is the only other option whose
   `secondary` shares the contact row, so it is the only other one that needs
   `:not(:has(...))` — the same selector, for the same reason, written at 2.3.35
   when an emptied `secondary` left two dead columns behind. A removed grid ITEM
   does not release its AREA.

   Measured: 286 -> 122 bare, 309 -> 145 with the skin. The largest single
   reduction in the set, and it closes it: all six options now carry a declared
   phone arrangement.

   THE 2px NOBODY SHOULD RE-DERIVE. 2c's slots land at 200 and 252 where the
   other five land at 198 and 250. That is not the arrangement, it is the
   GUTTER. Every other option takes the root padding `spacing--50` (16);
   `.ub-header__column` carries its own `padding-inline: var(--wp--preset--
   spacing--40)` — 14 — so this content box is 362 wide against everyone else's
   358.

   LEFT ALONE ON PURPOSE. At >=761 that padding is the inside of a 200px fixed
   rail, where 14 is a decision rather than a slip. Normalising it below the
   boundary only would make the two states disagree; normalising it at both
   would change the desk sidebar inside a cut about phones. It is recorded here
   so the 2px is a known quantity, and if the set's gutter should be uniform
   that is its own cut with a desk render behind it.

   NO UNIFY QUESTION HERE. 2c has no nav strip and no second collapse box — that
   one belongs to 2b and 2e alone. And no shell interaction: this grid is
   phone-scoped, the shell rules are >=761, and the two never meet.

   Not claimed: that a sidebar option should present as a bar at phone width at
   all. That is Ben's eye; this makes the degradation deliberate instead of
   accidental. */
@media (max-width: 760px) {
	.ub-header--sidebar .ub-header__column {
		display: grid;
		align-items: center;
		column-gap: var(--wp--preset--spacing--20);
		grid-template-columns: max-content minmax(0, 1fr) max-content max-content;
		grid-template-areas:
			"tree logo search actions"
			"contact contact secondary secondary";
	}

	.ub-header--sidebar .ub-header__tree { grid-area: tree; }
	.ub-header--sidebar .ub-header__logo { grid-area: logo; justify-content: center; }
	.ub-header--sidebar .ub-header__search { grid-area: search; }
	.ub-header--sidebar .ub-header__actions { grid-area: actions; justify-content: flex-end; }

	.ub-header--sidebar .ub-header__contact {
		grid-area: contact;
		min-inline-size: 0;
	}

	.ub-header--sidebar .ub-header__secondary {
		grid-area: secondary;
		min-inline-size: 0;
		justify-content: flex-end;
	}

	.ub-header--sidebar .ub-header__column:not(:has(.ub-header__secondary:not(:empty))) {
		grid-template-areas:
			"tree logo search actions"
			"contact contact contact contact";
	}
}

/* A LOGO NEVER OVERFLOWS ITS SLOT BELOW THE BOUNDARY — 2.3.33.
   ----------------------------------------------------------------------
   A chassis invariant, not a design opinion, which is why it is stated here and
   carries NO NUMBER. The first draft of this was a literal `max-inline-size:
   107px` — 107 being the column the arrangement above produced on bikeco. The
   objection that killed it is the one that matters: the column is not a
   constant. It is whatever is left after the controls, so a shop whose controls
   differ gets a different column and a literal overflows again. Write the
   column, not the measurement of one shop's column.

   THIS CANNOT BE ENFORCED FROM THIS FILE ALONE, and that was measured rather
   than argued. Ridgeline declares `block-size: 22px; max-inline-size: none` on
   the same element at the same specificity, and a child's stylesheet loads
   AFTER its parent's — so this rule, injected at parent position in a probe,
   computed `max-inline-size: none` and the mark stayed 150.8 in a 122px slot.
   Out-specifying from here would win and is exactly the silent-cascade move
   §16 exists to forbid. So the skin scopes its own 22px pin to >=761 in the
   paired 2.15.10, conceding that the pin was always a desk decision, and this
   rule is then uncontested rather than victorious.

   THE CHAIN WAS MEASURED, because `100%` resolves against whatever is directly
   above it and there are three wrappers in the way: slot -> `.wp-block-site-logo`
   -> `a.custom-logo-link` -> `img`. Nothing in it re-widens — the block wrapper
   tracks the column instead of filling past it and the inline link follows the
   mark. Cloned subtree, real bitmap, columns straddling the mark's natural 120:

     column   wrapper   link   mark          ratio
     160      120       120    119.9 x 17.5  6.86
     122      120       120    119.9 x 17.5  6.86
     100      100       100     99.9 x 14.6  6.86
      80       80        80     80.0 x 11.7  6.86
      60       60        60     60.0 x  8.8  6.86

   The source is 1858x271 — ratio 6.86 — held at every width, so `block-size:
   auto` scales the mark without distorting it, and the cap is inert above the
   mark's natural width and engages below it. Fit the column, no number.

   `block-size: auto` HAS ONE TRAP and it is an instrument trap, not a shipping
   one: on an image that has NOT loaded, auto height resolves to zero and the
   mark collapses. Every "it fits" reading taken against an unloaded bitmap is
   therefore a false pass, and this bench produced two of them before noticing.
   Any future check of this rule asserts `naturalWidth > 0` before believing a
   fit. */
@media (max-width: 760px) {
	.ub-header__logo {
		min-inline-size: 0;
	}

	.ub-header__logo img,
	.ub-header__logo .custom-logo {
		max-inline-size: 100%;
		block-size: auto;
	}
}

/* AND A SHOP WITH NO LOGO IMAGE HAD NO INVARIANT AT ALL — 2.3.35.
   ----------------------------------------------------------------------
   2.3.33 above says "a logo never overflows its slot below the boundary" and
   then wrote the rule for `img` and `.custom-logo` only. A shop that has not
   uploaded a logo renders `p.wp-block-site-title` instead — the block above at
   §1 hides the title only when an image IS present, by design, so the text is
   the fallback — and the fallback had no cap. Measured on range at 390:

     logo slot    66.5    the column the arrangement left it
     site title  134.8    overflowing by 68.3, overflow visible
     contact at   154.5   which is 20px inside the title's painted box

   So the title was painting across the contact part, and the three wrapped
   contact lines sitting under it read as one fault in one place. They are two
   faults: this one is the logo's, the height is the contact's, and they were
   reported together because they overlap on screen. The audit that found them
   was right about both and attributed both to the logo.

   ELLIPSIS, NOT SCALE, because text has no natural width to fit a column
   against — the image rule above can say `max-inline-size: 100%` and keep its
   ratio, and a wordmark has no ratio to keep. A clipped-with-ellipsis title in
   a 66.5px slot shows very little, and that is the honest presentation of a
   slot that small rather than a defect: the slot is whatever the controls leave,
   the degenerate case is already documented above as the invariant working, and
   the alternative — a title that silently covers its neighbour — is the thing
   being fixed. Measured after: 66.5, exactly the slot, contained.

   NO NUMBER HERE EITHER, for the same reason 2.3.33 carries none. */
@media (max-width: 760px) {
	.ub-header__logo .wp-block-site-title {
		min-inline-size: 0;
		overflow: hidden;
		text-overflow: ellipsis;
		white-space: nowrap;
	}
}

/* THE CONTACT PART NEEDS A SEPARATOR AND THE CHASSIS NEVER GAVE IT ONE —
   2.3.34, and this is a defect bare Bones has shipped since the part existed.
   ----------------------------------------------------------------------
   MEASURED ON RANGE (two-row, 390 and 1440): the phone runs straight into the
   address — "41524603452525 Arapahoe Ave, Boulder". One string, no space, at
   every width and in every option.

   THE CAUSE IS IN THE MARKUP AND IT IS NOT A BUG THERE. `class-contact.php`
   builds its rows as inline elements and joins them with `implode( '', $rows )`
   — no separator, no whitespace — then declares NOTHING on `.upline-contact`
   in its own layer. That is a deliberate boundary, not an omission: the six
   arrangements put these details on a line, in a column, and in an ellipsised
   bar, so the plugin ships no spacing and leaves it to whoever is arranging.
   `.upline-contact` is a designated surface precisely so a theme can.

   THE CHASSIS NEVER TOOK IT UP. Ridgeline did — `display: flex; flex-wrap:
   wrap; gap` — so bikeco reads correctly and the gap was invisible from there.
   Bones declared nothing, so BARE BONES glues the rows together. Range is bare
   Bones, which is why this surfaced the moment Ben flipped an option there and
   not in any of the fourteen releases before it.

   "Inherit it unchanged and the result should look unfinished, not like a
   theme" is this file's own promise, and unfinished is a fair description of a
   plain row of details. It is not a fair description of `41524603452525
   Arapahoe Ave`. A chassis may decline to style a surface; it may not render
   one unreadable.

   SO THE FLOOR, AND ONLY THE FLOOR. A wrapping row with the chassis's own two
   spacing tokens — the arrangement four of the six options want, and legible in
   the other two rather than correct in them. Every value here is a token with a
   fallback, nothing is a look, and a skin that wants a column, an ellipsis or a
   different rhythm overrides it with one plain declaration at equal specificity
   loading later. Ridgeline's existing rule already does exactly that and is
   untouched by this — which is the seam working in the direction it should,
   and the opposite of the logo case at 2.3.33 where the chassis needed the skin
   to stand down. */
.upline-contact {
	display: flex;
	flex-wrap: wrap;
	align-items: baseline;
	gap: var(--wp--custom--upline--gap-sm, 0.5rem) var(--wp--custom--upline--gap, 1rem);
}

/* Woo ships real `<svg>` children inside a wrapper. The wrapper becomes the
   slot's canvas and the glyph inside it is hidden, so no box is added and
   nothing shifts. Accessible names live in text, not in these glyphs. */
.wc-block-customer-account__link .wc-block-customer-account__visual svg,
.wc-block-mini-cart__button .wc-block-mini-cart__quantity-badge svg {
	display: none;
}

.wc-block-customer-account__link .wc-block-customer-account__visual {
	-webkit-mask-image: var(--wp--custom--ub--icon-account);
	mask-image: var(--wp--custom--ub--icon-account);
}

/* The cart is the one that cannot take the mask directly: its badge carries the
   item-count bubble as a child, and masking the badge would mask the count with
   it. The glyph goes on a pseudo-element filling the badge instead, so the
   count stays anchored to the icon exactly where Woo puts it. */
.wc-block-mini-cart__button .wc-block-mini-cart__quantity-badge {
	position: relative;
	inline-size: var(--wp--custom--ub--icon-size, 20px);
	block-size: var(--wp--custom--ub--icon-size, 20px);
}

.wc-block-mini-cart__button .wc-block-mini-cart__quantity-badge::before {
	content: "";
	position: absolute;
	inset: 0;
	-webkit-mask-image: var(--wp--custom--ub--icon-cart);
	mask-image: var(--wp--custom--ub--icon-cart);
}


/* -------------------------------------------------------------------------
   10. Inline-menu structural compensation
   -------------------------------------------------------------------------
   Moved up from Ridgeline, where it had no business being: this is structure,
   not look, and every Upline theme that puts the menu inline in the bar needs
   it identically.

   The mega panel opens at a fixed offset from its containing block, so it
   begins level with the bottom of the bar row. The menu's own list is shorter
   than the row and gets centred in it, which leaves a horizontal band of bar
   belonging to no list item, sitting directly between the link and the panel
   it opens. A pointer crossing that band loses `li:hover` and the panel shuts
   before it is reached. Measured on bikeco before the fix: a 10px dead zone;
   after, 1px, with the link box growing 44px -> 62px, still above the tap
   floor.

   Desktop only. A drawer toggle is not a hover target, and stretching a nav
   around one at phone widths is how the toggle ends up 661px wide.

   KNOWN LIMIT, and it is the plugin's to close: this only bridges the hovered
   item's own column. The panel spans the viewport while the item is ~81px
   wide, so every other column keeps a ~63px band belonging to neither box —
   reach diagonally and hover still goes false mid-traverse. The plugin's
   1.21.0 grace period is what actually makes that survivable; this rule closes
   the vertical gap, not the diagonal one. */
@media (min-width: 782px) {
	.ub-inline-menu,
	.ub-inline-menu nav,
	.ub-inline-menu nav > div {
		block-size: 100%;
	}

	.ub-inline-menu {
		align-self: stretch;
	}

	.ub-inline-menu nav > div > ul {
		block-size: 100%;
		align-items: stretch;
	}
}

/* -------------------------------------------------------------------------
   THE SALE ROUTE'S SECOND H1 — 2.3.19
   -------------------------------------------------------------------------
   MEASURED on bikeco, `/sale/fall-sale/`, facets 1.46.3. The sale route
   resolves to `post_type=product`, so WordPress renders it with
   `archive-product.html` — which means the plugin's sale heading and the
   template's own archive title both land on the page:

       <div class="upline-sale" data-upline-sale-state="live">
           <h1 class="upline-sale__title">Fall Sale</h1></div>
       <div class="ub-archive__head">
           …breadcrumbs…
           <h1 class="wp-block-query-title">Shop</h1></div>

   Two h1s, and the second one says "Shop" on a page whose subject is a sale.
   That is a document-outline defect rather than a styling preference, so it is
   the CHASSIS's to fix and every skin inherits it.

   WHY A SIBLING SELECTOR IS SAFE HERE, and why it was checked before it was
   written. The rule keys on `.upline-sale` being PRESENT. That is only sound if
   the block emits nothing at all off a sale route — an empty wrapper carrying
   the class would hide the archive title on every category page in the shop,
   which is about as bad as a CSS mistake gets. So it was read rather than
   assumed: on `/product-category/bikes/` the block emits ZERO MARKUP, no
   wrapper and no class, and `sale-promo` does the same on the sale page. The
   plugin's zero-markup contract holds in both directions, confirmed on both
   routes, and `[data-upline-sale-state]` is required as well so the hook is the
   rendered state rather than the class alone.

   `display: none` rather than a visual hide: this needs to leave the
   ACCESSIBILITY TREE, not just the viewport. A clipped-but-present h1 fixes
   what a sighted reader sees and leaves the duplicate heading in place for a
   screen reader, which is the half of the defect that matters more. The
   breadcrumb above it still carries "Home / Shop", so no context is lost.

   This styles the theme's OWN block and uses the plugin's element only as a
   condition — the plugin's ink and surfaces are untouched, so the @layer
   inversion is not in play.
   ------------------------------------------------------------------------- */

.upline-sale[data-upline-sale-state] ~ .ub-archive__head .wp-block-query-title {
	display: none;
}


/* -------------------------------------------------------------------------
   HEADER ROW PADDING, AND THE EMPTY SLOTS — 2.3.23
   -------------------------------------------------------------------------
   TWO CORRECTIONS TO THE HEADER PATTERNS, both of them things the patterns were
   doing that a chassis has no business doing.

   ── 1. The padding was an INLINE STYLE, which is a skin lockout ─────────────

   The patterns carried their row padding as `style="padding-top:…"` on the
   serialized markup, from a block `style.spacing.padding` attribute. An inline
   style cannot be beaten by a stylesheet at all short of `!important`, which gate
   6 forbids — so the header's spacing was the CHASSIS's decision and a skin could
   not have it. That contradicts the one thing Bones is for: it holds what is true
   of every Upline theme and has no design opinion on purpose.

   Measured rather than assumed, because the fix depends on it: Ridgeline enqueues
   its stylesheet with `array( 'upline-bones' )` as a dependency at priority 20, so
   the skin's sheet loads AFTER this one. Two class selectors of equal specificity,
   later wins — so a plain class rule here is genuinely overridable by one plain
   class rule there. No `!important` on either side, no specificity games, which is
   the same mechanism the sticky-header note already relies on.

   The VALUES ARE UNCHANGED. Each rule carries exactly the spacing its pattern had
   inline, so this release moves where the number lives and not what it is — and
   each is scoped to the element that actually had it. `.ub-header__secondary` is
   scoped to `--push` for that reason: only 2d's full-width secondary strip had
   padding, and a blanket rule would have added it to the four options whose
   secondary sits inline in a row.

   ── 2. The contact and secondary slots are EMPTY, so they must collapse ─────

   Term 3: an empty editorial region collapses; it never speaks. The patterns used
   to carry "Add your phone number" and friends, which spoke for a shop that had
   not spoken — to a shopper, who cannot tell a placeholder from a decision — and
   which no shop could edit, because pattern content is a theme file. All of it is
   gone; the groups remain as the named PLACE the inventory guarantees, holding
   nothing until a plugin block renders the shop's saved contact details.

   `:empty` is the whole mechanism and its limit is worth stating exactly, because
   it is the reason the patterns were written to close those divs with no gap: a
   container holding whitespace, a comment, or an empty paragraph is NOT `:empty`
   and will take its space. That is fine for markup this file ships beside, and it
   is a real edge for anything a shop later pastes in. Not chased with a `:has()`
   chain, which would be guessing at what "empty enough" means.

   Gate 19 carries both slots in AWAITING_RENDERER, so a green suite says "the
   place exists and its renderer is owed" rather than "the slot is filled".
   ------------------------------------------------------------------------- */

.ub-header__brand-row {
	padding-block: var(--wp--preset--spacing--40);
}

.ub-header__bar {
	padding-block: var(--wp--preset--spacing--30);
}

.ub-header__utility {
	padding-block: var(--wp--preset--spacing--20);
}

.ub-header--push .ub-header__secondary {
	padding-block: var(--wp--preset--spacing--20);
}

.ub-header__column {
	padding-block: var(--wp--preset--spacing--50);
	padding-inline: var(--wp--preset--spacing--40);
}

.ub-header__contact:empty,
.ub-header__secondary:empty {
	display: none;
}

/* ==== UB SHELL MODES — START (gate 23 boundary) ============================

   PAGE SHELLS FOR THE THREE HEADER OPTIONS THAT NEED ONE.

   WHY THIS SECTION IS DELIMITED. Term 4 clause 2 says these rules must be
   ADDITIVE: a page has to render correctly with none of them. That is a claim
   about the whole stylesheet, not about any one rule, and the only way to make
   it checkable rather than asserted is to put every shell rule in one region
   and let a gate prove the region contains nothing else. Gate 23 does exactly
   that — it fails if any `upline-shell` mention appears outside these markers,
   if any selector in here is not anchored on `body.upline-shell--*`, or if a
   shell name in here is not one the plugin's contract publishes. Delete from
   START to END and the remaining sheet is the 2.3.20 sheet, byte for byte.

   WHOSE NAME THIS IS. `upline-shell--sidebar|push|bottom-bar` are the PLUGIN's.
   It emits them on `<body>` via `body_class`, and it publishes them in two
   places this bench read directly: `contract/layouts.json`'s `header` key
   (`options[*].shell`) and the "Class on <body>" table in
   `contract/theme-tokens.md` at 1.49.2. An earlier draft had the theme emit
   `ub-shell--*`; that was superseded — a name in the plugin's output carries the
   plugin's namespace. The chassis's own structural knobs below keep the `ub-`
   prefix, because those ARE the theme's.

   THE DEGRADATION IS THE PLUGIN'S STATED INTENT, not this bench's guess: "A
   shell class on a theme with no rules for it renders the plain page." So a skin
   that overrides this stylesheet and ships no shell rules is not broken, and
   `push` having no rule set below is the same situation by a different route.

   -------------------------------------------------------------------------- */

/* The structural numbers, now declared ON THE SHELL THEY BELONG TO — 2.3.24.
   They sat on `:root` and needed a named exemption in gate 23 to do it, because
   `:root` is not anchored on a shell class and the region's whole claim is that
   everything in it is. The exemption was sound and it was also unnecessary: a
   knob only one shell reads belongs on that shell's own anchor, where it needs no
   carve-out at all. Gate 23's `:root` exemption is deleted with this change — an
   allowance nothing uses is a hole waiting for something else to fall through.

   THE SIDEBAR COLUMN IS NO LONGER THIS BENCH'S NUMBER. 1.50.0 publishes
   `header.options.sidebar.geometry` — column 200px, rail 56px, narrow_below 120px
   — so 200 has an authority and gate 23 now ASSERTS this declaration against that
   contract row. It was right when it was excavated; it is cited now, which is the
   difference between a number that happens to agree and one that cannot silently
   stop agreeing. The rail is deliberately NOT declared here: nothing consumes it
   until the collapse cut, and a name nothing renders is the defect this project
   has already paid for.

   2f's two bar heights are still §5's spec figures and still this bench's, because
   no contract row publishes them. Each is floored at the tap token where it is
   used, so a skin cannot shrink a bar below a fingertip by editing one number. */
/* THE COLUMN WIDTH IS A REGISTERED PROPERTY — 2.3.25, and what registration buys
   is interpolation, not function. MEASURED both ways in Chromium against this
   file's own rules: registered, the column reads 200px at rest, 128px at half the
   scroll range, 56px at the rail. Unregistered, the endpoints are identical and
   the midpoint reads 200px — an unregistered custom property animates DISCRETELY,
   so the column snaps from 200 to 56 in one frame at 50% progress. Both reach the
   right place; only one of them slides. Do not delete this block to "simplify":
   the rail still works and it starts jumping.

   Registering it also makes ONE TOKEN drive both boxes, which is the reason the
   rail is animated here rather than on the column and the page separately. The
   fixed column reads it for its width and `.wp-site-blocks` reads it for the space
   it reserves; measured at every step, the two agree — 200/200, 128/128, 56/56.
   Two animations on two elements could not promise that. */
@property --ub-shell-sidebar-w {
	syntax: '<length>';
	inherits: true;
	initial-value: 200px;
}

body.upline-shell--sidebar {
	--ub-shell-sidebar-w: 200px;
	--ub-shell-sidebar-rail: 56px;

	/* THIS IS NOT `narrow_below`, AND THE COINCIDENCE IS THE POINT. Both are
	   120px and they are different quantities: `narrow_below` is the plugin's
	   container-query threshold on its own bar, measured in bar width;
	   this is the SCROLL DISTANCE over which the column shrinks. Citing the
	   contract row here would be a false citation that happened to read
	   correctly, which is worse than an honest uncited number — so this one is
	   this bench's, and gate 23 checks the other two against the contract and
	   deliberately not this. */
	--ub-shell-sidebar-range: 120px;
}

body.upline-shell--bottom-bar {
	--ub-shell-top-bar-h: 52px;
	--ub-shell-bottom-bar-h: 56px;
}

/* ---- sidebar (2c) --------------------------------------------------------
   A persistent column down the inline-start edge, and the page moved over to
   make room. Scoped to the desk side of the PLUGIN's own menu boundary (761px,
   read from the contract's `families.menu.boundary.desktop_min`, not chosen
   here) because below it the plugin's menu is a drawer and a 200px column on a
   phone is not an arrangement, it is a mistake. Below the boundary the header
   stays in normal flow and this option renders as the plain stacked column the
   pattern already is.

   The 56px icon rail on scroll is NOT here. It is a scroll-driven collapse, and
   the one attempt at that primitive in this codebase was registered, `pending`
   and never ran. Out of this cut by the scoping doc's own §5, owed after the
   primitive is rebuilt and proven. */
@media (min-width: 761px) {

	body.upline-shell--sidebar .wp-site-blocks > header {
		position: fixed;
		inset-block: 0;
		inset-inline-start: 0;
		z-index: 90;
		width: var( --ub-shell-sidebar-w, 200px );
		overflow-y: auto;
		overscroll-behavior: contain;
	}

	body.upline-shell--sidebar .wp-site-blocks {
		padding-inline-start: var( --ub-shell-sidebar-w, 200px );
	}
}

/* ---- the sidebar's icon rail (2c), and §5's risk is retired ---------------
   The frame collapses the 200px column to a 56px icon rail on scroll. That was
   out of cut through 2.3.24 because the scoping doc's §5 recorded the mechanism
   as unproven: Ridgeline's one attempt was "registered, `pending: true` in
   getAnimations(), has never run, touches nothing".

   IT RUNS. Measured in Chromium against this file's own unthemed CSS, red then
   green, and the diagnosis is not what §5 assumed:

     short page, no scroll range   ScrollTimeline attached, `currentTime: null`,
                                   bar stays 72px — nothing moves
     tall page, real scroll range  `currentTime: "0%"` at rest, bar 72px -> 44px
                                   by scroll 184, playState `finished`

   So the mechanism was never broken. A scroll timeline on a document with NO
   SCROLL RANGE is INACTIVE — it has no progress to report — and an animation
   bound to it sits there looking registered and doing nothing, which is exactly
   what was observed and recorded as a defect. The original reading was taken on a
   page too short to scroll. One honest difference from §5's note: what this bench
   measured was `currentTime: null`, not `pending: true`; same cause, and the
   symptom is reported as measured rather than as remembered.

   Doctrine: an enhancement that reports "registered but idle" has two candidate
   causes — a broken rule, or a condition the rule needs that the test page did
   not provide — and only one of them is about the rule. Check the page before
   condemning the mechanism.

   WHY THE RAIL IS LOAD-BEARING NOW rather than decorative: the plugin keys its
   own narrow presentation on a container query over `.upline-menu__bar` at
   `narrow_below: 120px`, and that bar is sized by THIS column. Measured in the
   same harness: the bar tracks 200px -> 56px with the column, so it crosses the
   threshold and the plugin's narrow presentation follows with no coordination at
   all. That is the seam working; it is also why the theme could not keep leaving
   this unbuilt.

   `rail: 56px` is the contract's, from `header.options.sidebar.geometry.rail`, and
   gate 23 asserts it. Desk only, reduced-motion respected, and below the
   `@supports` floor the column simply stays 200px — a perfectly good sidebar. */
@media (min-width: 761px) and (prefers-reduced-motion: no-preference) {

	@supports (animation-timeline: scroll(root block)) {

		body.upline-shell--sidebar {
			animation: ub-shell-rail linear both;
			animation-timeline: scroll(root block);
			animation-range: 0 var( --ub-shell-sidebar-range, 120px );
		}

		@keyframes ub-shell-rail {
			to {
				--ub-shell-sidebar-w: var( --ub-shell-sidebar-rail, 56px );
			}
		}
	}
}

/* ---- push (2d) ----------------------------------------------------------
   BUILT AT 2.3.24. At 2.3.21 this shipped with NO RULES, because translating the
   page needs the theme to know the desk drawer is open and every signal available
   was either an undesignated class, an attribute that matched every dropdown, or
   script-set over a pure-CSS drawer. 1.50.0 publishes the answer:
   `header.openState` = `.upline-menu__switch:checked`, in the generated contract,
   which makes the checkbox API. Gate 6 permits that one selector BY DERIVATION
   from the contract rather than by a hand-added allowlist entry — the same
   arrangement gate 23 has for the shell names, and the reason the refusal at
   2.3.21 cost nothing: the allowlist was never widened, the plugin published.

   WHY THIS IS `padding-inline-start` AND NOT A TRANSFORM, which is the part worth
   reading before anyone "improves" it. `transform` on an element makes it the
   containing block for its fixed-position descendants. The plugin's drawer is
   `position: fixed` and lives INSIDE the header, so transforming `.wp-site-blocks`
   — or the header, or anything above the drawer — would capture the drawer and
   translate it along with the page. The push would cancel itself out exactly, and
   it would look like the rule simply had no effect. Excluding the header from the
   transform avoids the capture but leaves the bar behind while the content slides,
   which is the `drawer` option's behaviour and not 2d's. So the page is inset
   rather than slid: everything moves aside, the fixed drawer stays at the viewport
   edge, and nothing becomes a containing block for anything.

   This is derived from the transform spec, not measured. It is a render claim like
   any other in this region and it is owed on range — but it is the reason the
   obvious implementation is not the one here, which is worth more than the claim.

   DESK ONLY (761px, the plugin's own boundary from the contract, not a number
   chosen here): below it the drawer is a full-screen box, so there is no visible
   page left to push and inset space would be a gap behind an opaque panel.

   THE DISTANCE WAS AN UNDESIGNATED READ AND NO LONGER IS — closed at 1.51.3, and
   the way it closed is worth keeping because this bench's disclosure was only half
   right. 2.3.21 read `--upline-drawer-w` and said so: designated nowhere, gate 6
   does not check variables, "a disclosure rather than a check". What that note did
   NOT say is that the read was also NOT WORKING. The plugin declared both that
   variable and `--upline-menu-bar-h` on `.upline-menu`, and this rule is anchored
   on `body` — outside that subtree — so the variable was empty here and the push
   has been moving the page by the 360px FALLBACK the whole time. Correct distance,
   wrong mechanism, and nothing on this side could see it: a fallback that happens
   to equal the real value is indistinguishable from a working read.

   The plugin bench measured it on range and fixed it: at 1.51.3 both variables are
   declared on `:root` and nowhere else, and `drawerW` is a row in the tokens doc's
   variable table with `--upline-drawer-w` beside it. So the read is designated,
   reachable, and now actually resolves. Bones declares `upline.drawerW` so the
   chain has a theme end; a skin moves the panel and the push follows it.

   THE 360px FALLBACK STAYS, deliberately. It is no longer the value this rule
   expects to use — it is the value it uses on a plugin older than 1.51.3, where the
   variable is still scoped to `.upline-menu` and unreachable from here. Bones does
   not get to assume which plugin release a shop is on, and the fallback is the only
   thing standing between an old pairing and a page inset by zero.

   THE SAME FIX REOPENS A CHOICE THIS BENCH DECLINED, and it stays declined:
   `--upline-menu-bar-h` is now readable at `body` level too, so the bottom-bar's
   top bar could floor itself on the plugin's published height instead of
   `--wp--custom--upline--tap`. It resolves to the same value by the plugin's own
   construction — `var(--upline-tap, var(--wp--custom--upline--tap, 44px))` — and
   routing the chassis's own tap floor out through the plugin and back would add a
   dependency for no change in the number. Recorded so the next reader knows it was
   considered rather than missed.

   `:has()` is the mechanism. Where it is unsupported the page does not move and
   the drawer overlays it, which is the `drawer` option's presentation — a
   degradation, not a break, and the same shape as every other enhancement here. */
@media (min-width: 761px) {

	/* THE OFFSET FOLLOWS THE DRAWER'S LIVE WIDTH, NOT THE SHOP'S SETTING — 2.3.30.
	   ------------------------------------------------------------------------
	   `--upline-drawer-w` is what a shop SET. `--upline-drawer-live-w` is how wide the
	   drawer IS: the plugin widens it to 1.6x, capped at the viewport, while a third
	   menu step is open, because three panes do not fit in two panes' worth of box.
	   Offsetting by the setting leaves the page short by exactly that difference.

	   MEASURED, on 2.3.29 with plugin 1.53.2 at 1440x900, three steps open: the drawer
	   renders 576px and this rule offset the page 360px — 216px of drawer sitting over
	   the content. That number is what this change is judged against, and it is why
	   reading the setting was wrong rather than merely imprecise.

	   THREE LEVELS, AND EACH ONE NAMES A BUILD FACT — not a runtime state. This is
	   worth being exact about, because the obvious reading is wrong: the live property
	   is ALWAYS declared while the plugin's components.css is loaded (it is defined on
	   `:root` as `var(--upline-drawer-w)` and merely re-pointed while a third step is
	   open), so no fallback here ever fires because of what the menu is doing.

	     --upline-drawer-live-w   plugin 1.53.0 or newer, its stylesheet loaded
	     --upline-drawer-w        plugin stylesheet loaded, older than 1.53.0 —
	                              the live property does not exist in that build
	     360px                    no plugin stylesheet at all: bare Bones, or the
	                              plugin deactivated with the theme still installed

	   A comment saying "falls back to the token when the live width is absent" would
	   describe a condition that cannot occur, which is how a note outlives the thing
	   it explained.

	   WHY IT READS THE PROPERTY AND NOT `[data-upline-steps]`. The attribute is how the
	   plugin COMPUTES the property, and the tokens doc says so in as many words: "read
	   the property rather than the attribute; the attribute is how the property is
	   computed and may change." Gate 6 now refuses an attribute selector on it.

	   THE `:has( :checked )` GATE IS LOAD-BEARING AND STAYS. It answers a different
	   question from the live read — the gate makes the offset track whether the drawer
	   is THERE, the live read makes it track how WIDE it is. It is also, as of 1.53.3,
	   the only thing holding one line: the plugin's own close-clears fix is written but
	   cannot run (its `change` listener is guarded on a property the `Panes` constructor
	   never assigns), so `data-upline-steps` and the 1.6x live width persist after the
	   shopper closes the drawer. Measured by running the shipped script. Without this
	   gate, a closed drawer would hold the page offset at 576px — which is exactly the
	   shape of the tokens doc's own un-gated example snippet, and the reason not to copy
	   it.

	   NO CAP ON THE OFFSET, deliberately. The plugin caps the DRAWER at 100vw; nothing
	   caps the page, so a shop setting a wide drawer can push most of the viewport away
	   (drawer-w 700px gives a 1120px offset at 1440). A content floor is a number the
	   owner has to choose, and this rule will not invent one — routed, and a one-line
	   follow-up once it is ruled. */
	body.upline-shell--push:has( .upline-menu__switch:checked ) .wp-site-blocks {
		padding-inline-start: var( --upline-drawer-live-w, var( --upline-drawer-w, 360px ) );
	}
}

/* ---- bottom-bar (2f) ----------------------------------------------------
   Two fixed bars, the page reserving both, and the drawer stopping above the
   lower one.

   THE DRAWER INSET IS THE PLUGIN'S ASK, NOT THIS THEME'S IDEA.
   `components.css` consumes `bottom: var(--upline-drawer-bottom, 0px)` and its
   comment names this option's pattern as the thing expected to set it. Set here
   rather than inline in the pattern because a raw custom property in a block's
   serialized `style` attribute is not an attribute the block records, so the
   editor would report the block as invalid the first time a shop opened it.

   MEASURED LIMIT, carried as a finding: the plugin consumes that variable only
   inside `@media (min-width: 761px)`. Below the boundary its drawer is a fixed
   full-screen box with `bottom: 0`, so on a phone the open drawer covers this
   bar whatever the theme sets. Not compensated for here — the fix is a mobile
   consumption on the plugin's side, and a theme-side workaround would paint over
   the seam. */
body.upline-shell--bottom-bar {
	--upline-drawer-bottom: var( --ub-shell-bottom-bar-h, 56px );
}

/* THE TOP BAR STOPS BEING FIXED, AND THE RESERVATION IT NEEDED IS DELETED
   RATHER THAN CORRECTED — 2.3.35.
   ----------------------------------------------------------------------
   A page offset that is a CONSTANT is a claim about a height nobody measures.
   This option made that claim twice, and one of the two was already false:

     reserved  52px   `padding-block-start` from `--ub-shell-top-bar-h`
     rendered  74.4   range at 390, plugin 1.54.5 / Bones 2.3.33
     rendered  82.4   range at 390, plugin 1.55.2 / Bones 2.3.34
     hidden    30.4   of the page's own first content, under an opaque bar

   The second reservation — 56px for the bottom bar — was CORRECT, and only
   because the bottom bar happens to render at exactly its token. The foot
   cleared it by 0.2px. That is not a passing test, it is a coincidence with the
   same bug underneath it, and it would have failed the first time a shop put a
   second line in that bar.

   THE FIX IS A PRIMITIVE THIS FILE ALREADY HAD. `.wp-site-blocks > header
   .wp-block-template-part` has been `position: sticky; top: var(--ub-sticky-top,
   0px); z-index: 40` since 2.3.29 — and in THIS option it was measured doing
   nothing at all, because both of its children were `position: fixed` and so
   the wrapper rendered at HEIGHT ZERO. The shell re-implemented sticky with
   fixed positioning inside an element that was already sticky, and paid for it
   with a hand-maintained number. So the top bar goes back into normal flow and
   the wrapper does the work:

                            part h   bar pos   first content   scrollHeight
     fixed + 52 constant      0.0    fixed     y98  (= 46+52)      547
     in flow, no constant    82.4    static    y128.4 (= 46+82.4)  577
     in flow, scrollY 150    82.4    static    y-21.6, part y0     577

   Content now begins exactly where the bar ends, at whatever height the bar is,
   at every width, with nothing to keep in sync — and the +30.4 on scrollHeight
   is the overshoot that used to be hidden rather than new page.

   FOUR THINGS PROVED RATHER THAN ASSUMED, because this is a positioning change
   inside someone else's stacking order:

     1. The wrapper still sticks. At scrollY 150 the part's top is y0 and the
        bar is at the viewport top.
     2. The bottom bar is untouched. `position: sticky` does not establish a
        containing block for fixed descendants, so the bottom bar still resolves
        against the viewport — measured y344/h56 identical before and after, and
        the plugin's `--upline-drawer-bottom` reads the bottom token only.
     3. It still paints, which is the 2.3.29 defect's whole lesson. The bar
        computes `rgb(255, 255, 255)` and every hit test inside its box returns
        an element inside the bar while page content is scrolled under it, at
        390 and at 1440. Static kills the bar's own `z-index: 90`, so this is
        the wrapper's 40 doing it — which is what the other five options have
        always used.
     4. 1440 as well as 390: bar 52 at desk, below the admin bar at y32, sticky.

   `--ub-shell-top-bar-h` SURVIVES AS A FLOOR, NOT A RESERVATION. It is still the
   `min-height` below, where it says "this bar is at least this tall" — a claim
   about the bar itself, which the bar can keep. What it can no longer do is
   describe the bar to something else. */
body.upline-shell--bottom-bar .wp-site-blocks {
	padding-block-end: var( --ub-shell-bottom-bar-h, 56px );
}

body.upline-shell--bottom-bar .ub-shell-bar--top {
	min-height: max( var( --ub-shell-top-bar-h, 52px ), var( --wp--custom--upline--tap, 44px ) );
	background: var( --wp--preset--color--base, #fff );
}

/* AND THE BOTTOM BAR CARRIED THE SAME DEFECT, PASSING — 2.3.35.
   ----------------------------------------------------------------------
   This bar cannot leave `position: fixed`: there is no sticky-to-bottom in
   normal flow, so a bar pinned to the viewport foot has to be fixed and the
   page has to reserve for it. The reservation stays. What changes is whether it
   is TRUE.

   It was `min-height: max( --ub-shell-bottom-bar-h, tap )` against a
   `padding-block-end: var( --ub-shell-bottom-bar-h )`. THOSE TWO CANNOT BE THE
   SAME NUMBER: a floor says "at least this tall", a reservation says "exactly
   this tall", and the one token was doing both jobs with opposite meanings.
   The foot cleared the bar by 0.2px when it was measured — which is the
   identical bug to the top bar's, sitting in the identical shape, and passing
   only because this bar's content happens to fit inside 56. It would have gone
   red the first time a shop put two lines in it, and the symptom would have
   been a clipped footer rather than a hidden header, so nobody would have
   connected the two.

   SO THE HEIGHT IS PINNED, NOT FLOORED, and the reservation is then true for
   any value the token takes rather than for the value it happens to hold.
   `max()` survives because the tap floor is a real constraint — a shop that
   sets the token below 44px gets 44 and the reservation follows it. Measured on
   range, plugin 1.55.2, before and after the change, at 390 and at 1440: bar
   56.0 both ways, inner row 44.0, `scrollHeight` within `clientHeight`. It is a
   no-op on today's content, which is the point — it is not a fix for a visible
   defect, it is the removal of a coincidence.

   WHAT IT COSTS, said plainly: content taller than the pin now overflows the
   bar instead of growing it. That is a visible defect in the bar rather than an
   invisible one in the page, and the truncation rules below are what keep
   content inside it. A bar that silently grows and silently hides the page foot
   is the worse of the two, and it is the one this file shipped. */
body.upline-shell--bottom-bar .ub-shell-bar--bottom {
	position: fixed;
	inset-inline: 0;
	inset-block-end: 0;
	z-index: 95;
	block-size: max( var( --ub-shell-bottom-bar-h, 56px ), var( --wp--custom--upline--tap, 44px ) );
	background: var( --wp--preset--color--base, #fff );
}

/* Truncate, never wrap: a wrapped phone number is how a 52px bar becomes 78px
   and the fixed page offset stops matching the bar it was reserving. `min-width:
   0` is load-bearing — a flex item's default `min-width: auto` refuses to shrink
   below its content, so the ellipsis never arrives without it. */
body.upline-shell--bottom-bar .ub-shell-bar--top .ub-header__contact,
body.upline-shell--bottom-bar .ub-shell-bar--top .ub-header__secondary {
	min-width: 0;
}

/* THE RULE ABOVE WAS RIGHT AND THE ONE BELOW IT WAS AIMED AT NOTHING — 2.3.35.
   ----------------------------------------------------------------------
   The paragraph above predicted this defect exactly — "a wrapped phone number
   is how a 52px bar becomes 78px" — and then the rule it introduced selected
   `.ub-header__contact p`. THERE IS NO `p` IN THE CONTACT PART. It renders an
   `a.upline-contact__phone` and a `span.upline-contact__address` as direct
   children of `.upline-contact`, so the rule matched zero elements through
   every release that carried it, and the bar it was written to protect
   measured 74.4 against its own 52px reservation on range. The comment had the
   mechanism and the selector had the wrong element: being right about the
   failure is not the same as selecting the thing that fails.

   SO THE FIX IS WRITTEN AGAINST 2.3.34's CONTACT, NOT AGAINST THE RENDER THAT
   EXPOSED IT, and that distinction is the whole of why it is this shape.
   2.3.34 gave `.upline-contact` `display: flex; flex-wrap: wrap` so the part
   would stop gluing its rows together. That turns the overflow from a wrapping
   TEXT RUN into a wrapping FLEX LINE — `white-space: nowrap` on a child cannot
   stop a flex container from wrapping — and it made this bar WORSE, because
   each wrapped line now also carries the row gap. Measured on range at 390
   across the install:

     plugin 1.54.5 / pre-2.3.34   74.4   contact as one inline run, 3 lines
     plugin 1.55.2 / 2.3.34       82.4   contact as a wrapped flex line + gap
     with this rule               52.0   one line, exactly its reservation

   A fix tuned to the 74.4 render would have shipped `white-space` on a `p` and
   been wrong twice.

   THE PHONE DOES NOT ELLIPSISE AND THE ADDRESS DOES. Measured both ways in the
   same probe: letting both shrink gives a 44.9px phone off a natural 95 — a
   truncated telephone number, which is worse than no telephone number because
   it looks like one. Holding the phone at its content width spends the whole
   shortfall on the address instead (94.5 whole, address 109 of 334). The bar
   is 52.0 either way, so this is a choice about which string survives, and the
   tappable one wins.

   `> *` rather than the designated row names, deliberately: the rows are a
   published set the plugin adds to, and a new row type would wrap again under a
   named list. One level of children inside a designated hook is not structure
   walking. Ridgeline names the rows individually in its own bottom-bar block
   where it wants the specificity, and is unaffected by this. */
body.upline-shell--bottom-bar .ub-shell-bar--top .upline-contact {
	flex-wrap: nowrap;
	min-inline-size: 0;
}

body.upline-shell--bottom-bar .ub-shell-bar--top .upline-contact > * {
	min-inline-size: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

body.upline-shell--bottom-bar .ub-shell-bar--top .upline-contact > .upline-contact__phone {
	flex: 0 0 auto;
}

/* The secondary leg of the original rule is KEPT AND UNVERIFIED, which is said
   out loud rather than quietly resolved either way. The links part has rendered
   nothing since the plugin emptied it, so `.ub-header__secondary` is `:empty`
   and `display: none` on both benches and there is no `p` there to test the
   rule against — it cannot be shown to match and it cannot be shown to be dead.
   Deleting a rule on that basis would be the same move as writing one on it. */
body.upline-shell--bottom-bar .ub-shell-bar--top .ub-header__secondary p {
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

/* Collapse inline when empty, so a shop that deletes the placeholder copy gets a
   closed-up row rather than a gap holding nothing. `:empty` is the whole of the
   mechanism and its limit is worth stating: a group left with whitespace or an
   empty paragraph inside it is not `:empty` and will still take its gap. That is
   a real edge and it is reported rather than chased with a `:has()` chain that
   would be guessing at what "empty enough" means. */
body.upline-shell--bottom-bar .ub-shell-bar--top .ub-header__contact:empty,
body.upline-shell--bottom-bar .ub-shell-bar--top .ub-header__secondary:empty {
	display: none;
}

/* 2f's TOP BAR IS ARRANGED AT PHONE WIDTH — 2.3.37, and the reason it had to be
   is more useful than the rule.
   ----------------------------------------------------------------------
   2f ships shell plumbing at every width — the fixed bottom bar, both bar
   heights, the contact truncation — and NO arrangement below 761. Its top bar
   was a flex `nowrap` row of four slots left to work itself out. It did not.

   WHAT WENT WRONG, AND IT WAS LATENT RATHER THAN LIVE. Ridgeline declares, at
   top level with no media scope and no option scope:

       .ub-header__logo { flex: 1 0 100%; order: 1; ... }

   That is coherent where it was written: the skin turns `.ub-header__brand-row
   > div` into a WRAPPING row, and `flex: 1 0 100%` is how the logo takes a line
   of its own there. But the selector names the SLOT, not the slot in that ROW,
   so it also lands here — on a row that does not wrap. Basis 100% with
   `flex-shrink: 0` means the logo demands the whole 358 and cannot give any
   back, and the deficit falls entirely on the one sibling that can absorb it:
   `.ub-header__contact`, which 2.3.35 gave `min-width: 0` PRECISELY so it could
   truncate. Measured at 390 with the skin loaded: logo 358, contact 0. The
   phone still painted — the collapsed box does not clip — while the address and
   hours, inside `overflow: hidden`, were deleted from view. The slot inventory
   exists so that preferring an arrangement cannot cost a shop its contact
   details, and this cost them silently.

   NOBODY WAS SEEING IT, AND THAT IS RECORDED RATHER THAN GLOSSED. The defect
   needs Ridgeline AND 2f together, and no shop runs that pair: bikeco is
   Ridgeline with the drawer, range runs BARE BONES with no skin loaded at all.
   The first harness run that found it loaded both sheets and contradicted a
   live read of range that was perfectly correct — the contradiction was a
   missing fact about WHICH STACK each side was describing, not an error in
   either. Hence the rule that now governs every harness run: state which skin,
   if any, the run includes.

   WHY THE FIX IS HERE AND NOT IN THE SKIN. Scoping Ridgeline's rule to its own
   row was measured too: contact comes back, but the logo then shrinks to
   39.7–86.5 depending on how long the shop's address is, which makes the mark a
   function of the address. Rejected. An explicit grid area neutralises BOTH
   `flex` (inert on a grid item) and `order` (placement beats it), so the
   arrangement fixes the symptom at its own level and keeps doing so for any
   future skin rule of the same shape. That is also exactly WHY 2a was never
   exposed: 2a is arranged, and its grid already neutralises the same rule. The
   defect was never really the unscoped skin rule. It was an option with no
   declared arrangement leaving one free to land on it. Ridgeline still gets the
   scoping as hygiene, in its own cut, because a rule that means "in this row"
   should say so.

   MEASURED BOTH WAYS, because one stack is not the artifact:

     stack            logo              contact                  bar
     bare Bones       66.5  -> 66.5     219.5 -> 227.5, no clip   52 -> 52
     + Ridgeline      358   -> 96       0     -> 198, ellipsised  52 -> 52

   Range gains 8px of contact and clips nothing; the skin stack stops deleting
   the address. The logo holds its own width in both, which the skin-side fix
   could not do.

   Not claimed: that either shop's rendered bar looks right. These are
   geometries from a harness carrying real stylesheets and the plugin's own
   emitted DOM, calibrated against range's live `contact 219.5@154.5` and
   `bar 52` to the decimal, with a 4px residual on the logo's x that is named in
   the record and unexplained. The look is Ben's. */
@media (max-width: 760px) {
	body.upline-shell--bottom-bar .ub-shell-bar--top > .wp-block-group {
		display: grid;
		align-items: center;
		column-gap: var(--wp--preset--spacing--20);
		/* T-2f-1, 2.3.38 — see the note below this rule. */
		grid-template-columns: max-content minmax(0, 1fr) fit-content(65%);
		grid-template-areas: "menu logo contact";
	}

	body.upline-shell--bottom-bar .ub-shell-bar--top .ub-header__menu {
		grid-area: menu;
	}

	body.upline-shell--bottom-bar .ub-shell-bar--top .ub-header__logo {
		grid-area: logo;
		justify-content: flex-start;
	}

	body.upline-shell--bottom-bar .ub-shell-bar--top .ub-header__contact {
		grid-area: contact;
		justify-content: flex-end;
		min-inline-size: 0;
	}
}

/* THE LOGO COLUMN WAS `max-content` AND IT TOOK THE ROW — T-2f-1, 2.3.38.
   ----------------------------------------------------------------------
   2.3.37 arranged this bar and made range WORSE, which is the whole reason this
   note exists. Live at 390 on 2.3.37: columns 52 / 134.75 / 155.25 — the site
   title `range.theupline.co` is `nowrap` with a max-content of 134.8, the logo
   track resolved to exactly that, and the contact paid 64px for it. Address
   109 -> 44.7. Before the cut the flex row had SQUEEZED the title to 66.5 and
   contact kept 219.5, so the arrangement regressed the one bench running it.

   HOW THE HARNESS MISSED IT, recorded because the lesson is bigger than the
   bug: the logo stand-in was pinned at 66.5 — the width the OLD FLEX ROW had
   squeezed the title to. A pinned stand-in must carry the content's INTRINSIC
   width, never a width some previous layout produced, because the new layout is
   free to ask for the intrinsic one and here it did. Pinning a squeezed width
   hides exactly the question the arrangement is deciding.

   THE SHAPE IS NOW 2a's. In 2a the logo is the `1fr` REMAINDER between fixed
   tracks, so it is whatever is left after the controls — never a number anyone
   chose. Same here: `minmax(0, 1fr)` for the logo, and the title ellipsises
   inside it on the rule that has been in this file since 2.3.35. Range reads
   logo 78.5, contact 219.5, address 109 — its pre-cut numbers, restored.

   A FLAT `fit-content(96px)` CAP WAS THE OTHER CANDIDATE AND IT CANNOT WORK,
   by arithmetic rather than by taste. The row is fully determined:

       358 = 44 (menu) + 8 + L + 8 + C      so   L + C = 298
       L = 96     -> C = 202   (address 91.5)
       C = 219.5  -> L = 78.5

   `logo <= 96` and `contact >= 219.5` are 17.5px apart and cannot both hold.
   Measured, both skins: the 96 cap gives 96 / 202 / 91.5 and fails the contact
   and address floors. The `1fr` form satisfies all three at once.

   WHY CONTACT IS CAPPED TOO, AND WHY THE CAP IS A PROPORTION. With the logo as
   `1fr` and contact at plain `max-content`, a shop with a long address inverts
   the squeeze: measured, contact 464.5 in a 358 row and the logo at ZERO — the
   same defect this cut is fixing, mirrored. So contact gets a ceiling. It is a
   PERCENTAGE OF THE ROW rather than a pixel count, for the reason 2.3.33 gives
   two rules down: write the column, not the measurement of one shop's column.
   Swept at 390, bare Bones, long-address shop:

       55%  logo 101.1  contact 196.9  address  86.4   range FAILS
       60%  logo  83.2  contact 214.8  address 104.3   range FAILS
       62%  logo  76.0  contact 222.0  address 111.5   range passes
       65%  logo  65.3  contact 232.7  address 122.2   range passes
       70%  logo  47.4  contact 250.6  address 140.1   range passes
       75%  logo  29.5  contact 268.5  address 158.0   logo effectively lost

   At 62/65/70 the cap does not bind for range at all — its only job is the
   verbose shop, where it decides how much mark survives. 65% keeps the mark
   above 65px there while letting the address roughly double, and it is a
   proportion anyone can re-derive from this table rather than a figure taken
   off one install.

   Not claimed: that 65% is the only defensible proportion. It is the one this
   sweep supports, and the sweep is here so the next reader can disagree with
   evidence. */

/* ==== UB SHELL MODES — END (gate 23 boundary) ============================ */
