/*
 * Output CSS for Stage 2's starter block catalog (block-registry.php).
 * Enqueued only on singular page/post views where the builder is actually
 * enabled (kedynsierra-content-builder.php) - never loaded sitewide.
 *
 * .kcb-heading gets no rules here on purpose - it renders a real <h1>-<h6>
 * tag, which already inherits screen.css's real heading styling, including
 * its design tokens, for free. Same for .kcb-button, which also carries the
 * theme's real .button class.
 *
 * No spacing-scale token exists yet (docs/specs/DESIGN.md explicitly found
 * none to extract) - the gap values below are plain literals for now, a
 * candidate for tokenizing once/if a real spacing system exists.
 */

.kcb-paragraph {
	margin: 0 0 1em 0;
}

/*
 * render_paragraph() runs wpautop(), so ONE paragraph block emits several <p>
 * inside this div - but the margin above separates BLOCKS, never the <p>s
 * within one. reset.css zeroes p margins and the only other p rule on the site
 * is wordpress.css's `padding: 0.1em 0`, i.e. 1.6px a side. Multi-paragraph
 * body copy therefore sets about 3px apart, reading as one wrapped slab.
 * Latent until real prose lands in a block; certain the moment it does.
 */
.kcb-paragraph p {
	margin: 0 0 1em;
}

.kcb-paragraph p:last-child {
	margin-bottom: 0;
}

/*
 * Float release for classic-aligned images inside paragraph blocks.
 * Neither this file nor the theme styles `alignleft`/`alignright` - the
 * float comes from WP core's stylesheet - and a floated image's width is
 * fixed pixels while the viewport isn't: beside desktop-width text a
 * 255px float reads fine, but on a ~400px phone it leaves the adjacent
 * paragraph a strip a few characters wide, which screen.css's global
 * word-wrap:break-word then shreds into mid-word fragments. Below the
 * breakpoint the image becomes its own centered block instead;
 * max-width/height:auto keep an image whose HTML width attribute exceeds
 * the viewport from overflowing once it no longer floats. Scoped to
 * .kcb-paragraph: theme-owned templates size their own content images in
 * screen.css and are not this file's to change.
 */
@media (max-width: 767px) {
	.kcb-paragraph img.alignleft,
	.kcb-paragraph img.alignright {
		float: none;
		display: block;
		max-width: 100%;
		height: auto;
		margin: 0 auto 1em;
	}
}

.kcb-image {
	max-width: 100%;
	height: auto;
}

/*
 * align - margin-based, works identically for a plain
 * block-level image and a `row` grid child (grid items honor margin:auto
 * the same as block boxes) - see kedynsierra_builder_render_image()'s own
 * comment (blocks-media.php).
 */
.kcb-image--align-left {
	display: block;
	margin-right: auto;
}
.kcb-image--align-center {
	display: block;
	margin-left: auto;
	margin-right: auto;
}
.kcb-image--align-right {
	display: block;
	margin-left: auto;
}

/*
 * An image sitting beside a paragraph in a `row` should visually match the
 * row's real height instead of leaving whitespace above/below itself
 * (Kedyn's live ask: "image size should be dynamic to paragraph w×h").
 * CSS grid's default align-items:stretch already makes every child's own
 * grid CELL span the row's full height (the tallest sibling sets it) with
 * no extra code needed - object-fit:cover here makes the <img> itself fill
 * that already-stretched cell instead of just sitting top-aligned inside
 * it. Scoped to a direct .kcb-row child specifically so a standalone image
 * elsewhere on the page (using its own natural aspect ratio) is untouched.
 */
.kcb-row > .kcb-image {
	width: 100%;
	height: 100%;
	object-fit: cover;
}

.kcb-image-grid {
	display: grid;
	/*
	 * minmax(0, 1fr), not a bare 1fr (A7, 2026-08-26 audit). The long note on
	 * the 767px collapse below explains the min-content-floor failure - but
	 * that correction was scoped INSIDE the phone query, so above 768px the
	 * floor still applied and columns rendered visibly unequal. Measured on
	 * /contact/ at 768px: 270.7px against 362.3px in a nominally 50/50 row, a
	 * 91px imbalance, only settling by about 900px.
	 *
	 * It belongs on the base rule because it is correct at EVERY width -
	 * minmax(0, ...) can only ever let a track shrink to its container, never
	 * make one wider. Applied to the two other blocks sharing this exact
	 * declaration (.kcb-people-grid, .kcb-listing) and to .kcb-row for the
	 * same reason.
	 */
	grid-template-columns: repeat(var(--kcb-grid-columns, 3), minmax(0, 1fr));
	gap: var(--kcb-grid-gap, 20px);
}

.kcb-image-grid__item {
	margin: 0;
}

/* The lightbox anchor render_image_grid() wraps each photo in. An inline
   anchor would sit on the text baseline and leave a few px of descender gap
   under every image in the grid; block makes it wrap the image exactly.
   height:100% chains the grid item's own stretched cell height (CSS grid's
   default align-items:stretch) down through the anchor to the <img> below,
   so a photo with a different native aspect ratio than its row-mates fills
   the same cell instead of sitting top-aligned with empty space beneath it. */
.kcb-image-grid__item > .fancy-gallery {
	display: block;
	height: 100%;
}

/*
 * object-fit:cover crops to fill the stretched cell rather than distorting
 * the photo - same reasoning as .kcb-row > .kcb-image above. Uniform cell
 * dimensions across a row matter more here than preserving every photo's
 * exact native crop, since image-grid rows are meant to read as one aligned
 * strip, not a masonry layout.
 */
.kcb-image-grid__image {
	display: block;
	width: 100%;
	height: 100%;
	object-fit: cover;
}

.kcb-image-grid__caption {
	font-size: 13px;
	margin-top: 0.5em;
}

/*
 * gallery-horizontal - pure CSS scroll-snap strip, no
 * JS dependency (matches this file's own "one parallax library, no redundant
 * animation engines" page-weight posture - a horizontal-scrolling row of
 * images doesn't need a carousel library when overflow-x + scroll-snap
 * already does it natively). `-webkit-overflow-scrolling` is a harmless no-op
 * on browsers that don't need it (momentum scrolling ships built-in
 * everywhere else); kept for older iOS Safari specifically.
 */
.kcb-gallery-horizontal {
	display: flex;
	overflow-x: auto;
	gap: 20px;
	scroll-snap-type: x mandatory;
	-webkit-overflow-scrolling: touch;
}

.kcb-gallery-horizontal__item {
	flex: 0 0 var(--kcb-gallery-horizontal-item-width, 320px);
	scroll-snap-align: start;
	margin: 0;
}

.kcb-gallery-horizontal__image {
	display: block;
	width: 100%;
	height: auto;
}

.kcb-gallery-horizontal__caption {
	font-size: 13px;
	margin-top: 0.5em;
}

.kcb-row {
	display: grid;
	grid-template-columns: repeat(var(--kcb-row-columns, 2), minmax(0, 1fr));
	/* Fallback is this rule's own pre-existing hardcoded value, so a row
	 * saved before `gap` existed renders unchanged - same
	 * custom-property-with-fallback shape as --kcb-grid-gap above. */
	gap: var(--kcb-row-gap, 20px);
}

/*
 * A8 (2026-08-26 audit): a row cell holding ONLY an image must not stretch.
 *
 * .kcb-row is a grid, so its children default to align-self:stretch and take
 * the height of the tallest cell. When the cell is a .kcb-style-wrap carrying
 * a visible shadow or border, that frame is then drawn around a large block
 * of empty space below the photo.
 *
 * Measured on live /about/, whose portrait sits beside a much taller text
 * column:
 *     900px  -> image 520px tall, wrapper 957px  = 437px of empty frame
 *    1150px  -> image 603px tall, wrapper 803px  = 200px of empty frame
 * Worse the narrower the viewport, because the text column grows while the
 * image does not.
 *
 * Deliberately NOT a blanket `.kcb-row > .kcb-style-wrap { align-self: start }`.
 * Stretch is doing real work for other block types - two content-bg-image
 * partner cards of unequal natural height rely on it to line up, and killing
 * it there would make that raggedness worse, not better. Scoped with
 * :only-child so it applies to a wrapper whose entire content is one image
 * block and nothing else. Verified after: /about/ overhang 437px -> 0, while
 * /contact/'s partner cards stayed at their existing 290/345 heights.
 */
.kcb-row > .kcb-style-wrap:has(> .kcb-image:only-child) {
	align-self: start;
}

/*
 * Breaks a row out of the theme's centered content column so a photo band
 * runs edge-to-edge, the width the legacy templates got by placing such a
 * banner in a different wrapper chain than the body copy - something a
 * single the_content() call can't express, since it emits both.
 *
 * Same `calc(-1 * (50vw - 50%))` idiom as .kcb-image-half-bleed below: the
 * 50% resolves against the containing block, so the pair of negative
 * margins widens the row to the viewport whatever column it sits in, with
 * no hardcoded 960px anywhere. Opt-in per row (blocks-layout.php's
 * `full_bleed` prop), so no existing tree is affected.
 *
 * Held to 768px+ for the same reason every other multi-column rule in this
 * file is: below that the content column is already near-full-width, so
 * there is nothing to break out of.
 *
 * 50vw counts the scrollbar gutter on the platforms that reserve one, so a
 * full-bleed row can overhang by that width - verify in a real browser
 * before relying on it, and if it bites, the fix belongs on an ancestor
 * (overflow-x) rather than here.
 */
@media (min-width: 768px) {
	.kcb-row--full-bleed {
		margin-left: calc(-1 * (50vw - 50%));
		margin-right: calc(-1 * (50vw - 50%));
	}
}

/*
 * Freeform (Stage 4): unlike .kcb-row above, render_freeform()/
 * wrap_freeform_item() (blocks-layout.php) already inline every real
 * position/size value directly (position/height/left/top/width are all
 * per-instance JS-computed numbers, not a few-valued setting like `columns`
 * that suits a custom property + stylesheet rule better) - these two rules
 * are a redundant backstop, not load-bearing today, matching this plugin's
 * existing defense-in-depth habit (renderer.php's own docblock) rather than
 * assuming the inline styles alone are enough forever.
 */
.kcb-freeform {
	position: relative;
	overflow: hidden;
}

.kcb-freeform__item {
	position: absolute;
}

/*
 * Shared mobile collapse. ONLY list selectors whose base
 * `grid-template-columns` is declared ABOVE this block.
 *
 * Both rules carry the same (0,1,0) specificity, so source order decides the
 * winner - a base rule declared LATER in this file silently beats this media
 * query and the block never collapses on a phone. `.kcb-text-columns` (:304)
 * and `.kcb-card-row` (:1186) were both listed here while being declared
 * hundreds of lines below, so neither collapsed: on 2026-08-25 the four-item
 * stats strip on /ten-years-later/ held four tracks at 375px, overflowing the
 * grid to 439px inside a 315px container and making the whole page
 * horizontally scrollable (the header then stretched to the widened document,
 * which is why the logo appeared to scroll off the left edge). Each now has
 * its own correctly-ordered collapse immediately after its base rule, the
 * same pattern `.kcb-text-sidebar` (:282/:293) already used.
 */
/*
 * Tablet step for the image grid (A6, 2026-08-26 audit).
 *
 * .kcb-image-grid stepped straight from its base 3 columns to 1 column at
 * 767px, so 768-1023px sat on the full desktop count. Measured live on
 * /ten-years-later/, which carries TWELVE of these grids: at 760px a photo
 * rendered 700x1050; at 768px the same photo rendered 218x326. An 8px change
 * in viewport width shrank his photographs to under a third of their size.
 *
 * Two columns through the tablet band. Ordered BEFORE the 767px query so the
 * phone rule still wins below it - equal specificity, last match wins.
 * .kcb-row is deliberately excluded: it is a 2-column layout already, and
 * the A7 base-rule fix above is what it actually needed.
 */
@media (max-width: 1023px) {
	.kcb-image-grid {
		grid-template-columns: repeat(2, minmax(0, 1fr));
	}
}

@media (max-width: 767px) {
	.kcb-image-grid,
	.kcb-row {
		/*
		 * `minmax(0, 1fr)`, not a bare `1fr`. `1fr` is shorthand for
		 * `minmax(auto, 1fr)`, and that `auto` floor is the item's min-content
		 * width - so a grid item that cannot shrink any further makes the TRACK
		 * wider than the grid itself, and the block overflows sideways out of
		 * its own container.
		 *
		 * Observed live 2026-08-25 on /contact/ at 375px: the two partner cards
		 * (content-bg-image blocks inside a one-column .kcb-row) computed
		 * `grid-template-columns: 354.266px` inside a 275px row, so each card
		 * ran from x=50 to x=404 - 29px past the 375px viewport, clipped at the
		 * right edge with a gap on the left. `minmax(0, ...)` lets the track
		 * shrink to the row and the cards sit at 50..325 as intended.
		 *
		 * Same failure this file's stats-strip collapse already documents; the
		 * two are the same bug in different blocks.
		 */
		grid-template-columns: minmax(0, 1fr);
	}
}

/*
 * Pull quote - a real <blockquote> (kedynsierra_builder_render_
 * pull_quote(), blocks-content.php), styled with a left rule + larger italic
 * type rather than a full custom card (no design-token/color system exists
 * yet for this to draw on beyond `currentColor`, same "plain literals for
 * now" posture as this file's own top docblock already states for spacing).
 */
.kcb-pull-quote {
	margin: 0 0 1em 0;
	padding: 0 0 0 24px;
	border-left: 4px solid currentColor;
	font-size: 1.4em;
	font-style: italic;
	line-height: 1.4;
	/*
	 * These three undo screen.css's bare `blockquote` rule, which this block
	 * renders a real <blockquote> into and so inherits wholesale:
	 * `opacity: .4; text-transform: uppercase; letter-spacing: 2px`.
	 *
	 * opacity is the serious one - it composites, so a parent's opacity:1
	 * cannot undo it, and it drags every pull-quote under WCAG AA on both
	 * grounds (about 2.4:1 on white, 3.4:1 on the dark band even after the
	 * colour fix above). That exact failure has already been patched twice,
	 * per-page, by style.css and ten-years-later-galleries.css; the first of
	 * those claims in a comment that no other <blockquote> exists in either
	 * theme - true when written, false since this block shipped.
	 *
	 * The caps are defensible for a six-word epigraph and unreadable for the
	 * 326-character February 2015 quotation this page carries.
	 */
	opacity: 1;
	text-transform: none;
	letter-spacing: normal;
}

.kcb-pull-quote__text {
	margin: 0 0 0.5em 0;
}

.kcb-pull-quote__attribution {
	font-size: 0.6em;
	font-style: normal;
	font-weight: 600;
}

.kcb-pull-quote__role {
	font-weight: 400;
	opacity: 0.75;
}

/*
 * Text with sidebar - legacy `ppb_text_sidebar`
 * ("Text With Sidebar"). The legacy field was a picker for one of this
 * theme's registered widget areas (lib/sidebar.lib.php's tg_register_
 * sidebars()) rendered via dynamic_sidebar() next to the main body - not
 * ported literally here (no other block in this registry reaches into the
 * legacy widget-area system, and the plugin that held the actual
 * ppb_text_sidebar render markup, photome-custom-post, does not exist in
 * this checkout to confirm its exact shape against). Modeled instead as a
 * second self-contained rich-text zone, same "two dedicated props, no
 * repeater" shape as .kcb-text-columns just below - asymmetric 2fr/1fr
 * instead of text-columns' even 1fr/1fr split, since a sidebar reads as
 * narrower/secondary rather than an equal peer
 * (kedynsierra_builder_render_text_sidebar(), blocks-content.php). The
 * sidebar zone renders as a real <aside> for the same reason (complementary,
 * not primary, content).
 *
 * Own dedicated @media rule below rather than joining the shared 767px
 * collapse list earlier in this file (.kcb-image-grid/.kcb-row/.kcb-text-
 * columns/.kcb-card-row) - avoids two lanes editing that same shared
 * selector list at once.
 */
.kcb-text-sidebar {
	display: grid;
	grid-template-columns: 2fr 1fr;
	gap: 20px;
}

.kcb-text-sidebar__main > *:last-child,
.kcb-text-sidebar__sidebar > *:last-child {
	margin-bottom: 0;
}

@media (max-width: 767px) {
	.kcb-text-sidebar {
		grid-template-columns: 1fr;
	}
}

/*
 * Two-column text - side-by-side text without hand-building a
 * `row` + 2 paragraphs (kedynsierra_builder_render_text_columns(),
 * blocks-content.php). Collapses to a single column at the same 767px
 * breakpoint every other multi-column block type here already uses - via its
 * own @media rule immediately BELOW its base rule, not the shared list near
 * the top of this file (which it cannot use: it is declared after that list,
 * so at equal specificity the base rule would win and the collapse would
 * never fire).
 */
.kcb-text-columns {
	display: grid;
	grid-template-columns: 1fr 1fr;
	gap: 20px;
}

/*
 * Placed AFTER the base rule, not in the shared list above - see that block's
 * docblock for why an equal-specificity media query above a base rule loses.
 */
@media (max-width: 767px) {
	.kcb-text-columns {
		grid-template-columns: 1fr;
	}
}

.kcb-text-columns__col > *:last-child {
	margin-bottom: 0;
}

/*
 * Image lightbox - kedynsierra_builder_render_image_lightbox()
 * (blocks-media.php). Reuses .kcb-image's own max-width:100%/height:auto
 * rule (top of this file) via the shared kcb-image class on the <img> itself
 * - only the wrapping <figure>/link/caption need their own rules here.
 */
.kcb-lightbox-image {
	margin: 0 0 1em 0;
}

.kcb-lightbox-image--align-left {
	text-align: left;
}

.kcb-lightbox-image--align-center {
	text-align: center;
}

.kcb-lightbox-image--align-right {
	text-align: right;
}

.kcb-lightbox-image__link {
	display: inline-block;
	cursor: zoom-in;
}

.kcb-lightbox-image__caption {
	font-size: 13px;
	margin-top: 0.5em;
}

/*
 * gallery-slider lightbox links -
 * kedynsierra_builder_render_gallery_slider()'s
 * `.kcb-gallery-slider__link` anchor (blocks-media.php), bound to
 * PhotoSwipe by includes/render/blocks.js. Same zoom-in affordance as
 * .kcb-lightbox-image__link above; `display:block` (not inline-block) so the
 * link fills the <li> the same way the wrapped <img> would on its own - no
 * layout change to the existing flexslider markup.
 */
.kcb-gallery-slider__link {
	display: block;
	cursor: zoom-in;
}

/*
 * Slide images must fill the slider, not just fit it. The vendored
 * flexslider.css gives `.slides > li img` only max-width:100%, so a
 * slide's rendered width is really min(intrinsic width, container) -
 * that looks full-bleed whenever srcset delivers a candidate at least
 * as wide as the container (typical on desktop) and silently collapses
 * to the candidate's own size when it doesn't (small viewports pick
 * small candidates, and a missing intermediate size shrinks the choice
 * further), leaving the photo at partial width with the overlay
 * controls floating on the empty remainder. Layout must not ride on
 * that lottery: pin the width to the container and let srcset affect
 * sharpness only. height:auto keeps the img's width/height HTML
 * attributes from skewing the ratio once width is forced; display:block
 * drops the inline baseline gap and makes text-align moot. Scoped to
 * this plugin's sliders: the theme's vendor-markup sliders size their
 * images in screen.css and are not this file's to change. Thumbnails
 * (.flex-control-thumbs) sit outside .slides and are untouched.
 */
.kcb-gallery-slider .flexslider .slides li img,
.kcb-portfolio-slider .flexslider .slides li img {
	width: 100%;
	height: auto;
	display: block;
}

/*
 * Height-clip safety net for the vendored FlexSlider's own float-stacked
 * `.slides` list (width:100%; float:left; margin-right:-100%, so every slide
 * overlaps the same row - core/flexslider.css's own `.slides:after` clearfix
 * makes the <ul>'s auto-height equal to the TALLEST slide, not just the
 * active one). FlexSlider's smoothHeight sets an explicit inline height on
 * `.flexslider` matching the active slide, and USUALLY pairs it with
 * `overflow: hidden` - but not on every code path (its own resize/height-
 * update pass can re-set just the height and drop the overflow), and when
 * that happens any taller inactive-but-still-floated-and-visible slide bleeds
 * straight past the correctly-sized box, showing as a large blank gap under
 * the slider before the next block. `overflow: hidden` here is unconditional
 * (not dependent on FlexSlider's own inline style including it) and is a
 * pure safety net: FlexSlider's inline height is already correct whenever
 * this matters, so this rule only ever clips content that inline style
 * already intended to hide.
 *
 * Coverflow/scattered are excluded: their flanking cards are translated
 * +/-62% specifically to peek past the slider's own edge (the point of
 * those layouts), and this file already un-clips their .slider_wrapper
 * (overflow: visible, in the coverflow/scattered section) for exactly that
 * reason - a clip here, one level below that override, would amputate the
 * peek at the .flexslider box and dead-letter the wrapper rule. The trade
 * is deliberate: those two layouts keep their unclipped look and forgo
 * this safety net (their steady state doesn't need it - FlexSlider's fade
 * mode absolutely positions inactive slides, so the <ul> tracks the
 * active slide's height; the degraded-state gap this net catches remains
 * possible there, as it always was, because clipping is not an available
 * mitigation for a design built on visible overflow).
 */
.kcb-gallery-slider:not(.kcb-gallery-slider--coverflow):not(.kcb-gallery-slider--scattered) .flexslider,
.kcb-portfolio-slider:not(.kcb-portfolio-slider--coverflow):not(.kcb-portfolio-slider--scattered) .flexslider {
	overflow: hidden;
}

/*
 * Pre-init height floor. FlexSlider's vendored base CSS starts every
 * `<li>` at `display: none` until its own JS explicitly unhides the
 * active one - so before that JS runs, `.flexslider` has nothing in
 * normal flow to size itself by and collapses toward 0 height, letting
 * the next block on the page render up into that space.
 *
 * Previously used `aspect-ratio: 3/2`, disarmed once FlexSlider's own
 * smoothHeight set a real inline height. That measurement race was worse
 * than assumed: on this site's vendored FlexSlider build, `.flexslider`
 * itself is still governed by the aspect-ratio at the exact moment
 * smoothHeight takes its first measurement (confirmed live on
 * /ten-years-later/ - every gallery's `.flexslider` had baked in a
 * literal 3:2 box, e.g. a 1200x648/1.852-ratio photo permanently
 * measured and inlined at 1.500, regardless of viewport). The bad
 * measurement then gets written into an inline `height:Xpx`, which
 * outranks any later stylesheet rule - so the corruption was permanent,
 * not just a pre-init flash. `.flex-viewport` never exists on this
 * vendored fade-mode build at all (confirmed live: init completes,
 * `.flex-control-nav`/`.flex-direction-nav` both get created, no
 * `.flex-viewport` ever appears), so the `:not(:has(.flex-viewport))`
 * guard this used to carry never actually disarmed anything for the
 * gallery's own default animation - only for portfolio-slider's slide
 * mode, where the exact same poisoned-measurement failure mode was never
 * checked for and is equally live.
 *
 * `min-height` instead of `aspect-ratio`: a *floor* only, never a
 * *target* - it can't feed back into smoothHeight's own measurement the
 * way a used-height-supplying property can, since min-height only ever
 * raises a height that's otherwise smaller, it doesn't get read back as
 * "the" height the way aspect-ratio's computed value does. 200px is
 * arbitrary but modest - enough to keep two adjacent instances from
 * visibly overlapping during the sub-second gap before JS runs, without
 * being large enough to itself cause a visible layout jump once the real
 * height lands.
 */
/*
 * ...but ONLY until kcb-slider.js has measured a real slide (2026-08-29).
 *
 * This floor exists for the moment before the engine runs, when every
 * <li> is still display:none and the container has nothing in flow to
 * size itself by. Once the engine sets an explicit height the floor has
 * no job left - and on a phone it actively causes the bug it was never
 * meant to: a wide landscape frame at 390px wide renders about 140px
 * tall, so a 200px floor held the container ~60px taller than the
 * photograph and left a blank bar under it. Reproduced on a cold,
 * throttled mobile load of /ten-years-later/: container pinned at 200px
 * while slides measured 131, 146, 158 and 189px. Desktop never showed
 * it because those same frames are 400-900px tall there, always clear
 * of the floor - which is why this survived the FlexSlider retirement.
 *
 * .kcb-slider-ready is added by syncHeight() the first time it commits a
 * height, so the handover happens exactly when there is something to
 * hand over to.
 */
.kcb-gallery-slider:not(.kcb-slider-ready) .flexslider,
.kcb-portfolio-slider:not(.kcb-slider-ready) .flexslider {
	min-height: 200px;
}

/*
 * Loading tone (2026-08-22 homepage/site review pass, matching css/
 * portfolio-grid.css's identical grid-placeholder value): sliders spend
 * real time as an empty box while their lazy-loaded slide images arrive
 * (loading="lazy" is forced onto every <img> in this stack - see the
 * deferred-init comments in the two script-*-flexslider.php files), and a
 * stark white box reads as broken. A quiet warm grey reads as a
 * deliberate placeholder instead. The bare `.slider_wrapper .flexslider`
 * variant covers the theme's own non-kcb gallery markup too (e.g.
 * /ten-years-later/'s 12 galleries) - blocks.css is enqueued on every
 * builder-content page, which all of those are. Invisible once a slide
 * paints: slide images span the full container width.
 *
 * Coverflow/scattered are excluded (same :not() pattern as the overflow
 * safety net above): their cards are scaled down/translated to float on
 * the PAGE background by design, so a permanent grey slab behind them
 * would read as part of the layout, not a loading state.
 */
.kcb-gallery-slider:not(.kcb-gallery-slider--coverflow):not(.kcb-gallery-slider--scattered) .flexslider,
.kcb-portfolio-slider:not(.kcb-portfolio-slider--coverflow):not(.kcb-portfolio-slider--scattered) .flexslider,
.slider_wrapper:not(.kcb-gallery-slider):not(.kcb-portfolio-slider) .flexslider {
	background-color: var(--surface-warm, #edebe7);
}

/*
 * Presentation size cap for gallery-slider blocks. `.slides > li img`
 * above is `width:100%; height:auto`, so the carousel's rendered size is a
 * pure function of this wrapper's width - and nothing in single-portfolio's
 * markup constrains that width anywhere in its ancestor chain (confirmed
 * live: every element from `.flexslider` up to `<body>` measured exactly
 * viewport width), so on a typical desktop the slider filled the page
 * edge-to-edge at 2200px+. Capping width here, not height directly (which
 * would force object-fit cropping or letterboxing against the height:auto
 * architecture above), lands close to the legacy `data-height="750"`
 * markup attribute already baked into templates/gallery-slider.php - that
 * attribute is inert (FlexSlider and every init script in this theme only
 * ever read `data-height` for the unrelated parallax feature), but its
 * value is a reasonable signal for the comfortable size this was meant to
 * read at. `overflow: hidden` on this same element (theme's own
 * `.slider_wrapper` base rule, css/screen.css) does not clip this
 * box-shadow - overflow only clips a box's children, never that box's own
 * shadow/outline.
 */
.kcb-gallery-slider {
	max-width: 1100px;
	margin-left: auto;
	margin-right: auto;
	box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.08);
}

/*
 * Per-device visibility toggles (Workstream A item 2, style-props.php's
 * kedynsierra_builder_get_visibility_classes()) - a small fixed set of
 * static utility classes, not real per-node generated CSS (deferred, see
 * style-props.php's own docblock). Mobile's 767px boundary matches the
 * @media rule directly above (this plugin's own existing convention);
 * 1024px as the tablet/desktop split is a plain default, not a discovered
 * project breakpoint - nothing else in this codebase draws that line.
 */
@media (max-width: 767px) {
	.kcb-hide-mobile {
		display: none;
	}
}

@media (min-width: 768px) and (max-width: 1024px) {
	.kcb-hide-tablet {
		display: none;
	}
}

@media (min-width: 1025px) {
	.kcb-hide-desktop {
		display: none;
	}
}

/*
 * Audio block - a light shell around the native <audio
 * controls> element, not a from-scratch custom player (see
 * kedynsierra_builder_render_audio()'s own docblock, blocks-media.php, for
 * why that's the deliberate scope here). `accent-color` themes the native
 * play/volume/seek controls in every browser that supports it (all current
 * evergreen browsers) with zero custom control markup/JS to keep in sync
 * with browser-specific audio UI internals.
 */
.kcb-audio-player {
	max-width: 480px;
	padding: 16px;
	border: 1px solid rgba(0, 0, 0, 0.1);
	border-radius: 8px;
	background: rgba(0, 0, 0, 0.02);
}

.kcb-audio-player__title {
	font-weight: 600;
	margin-bottom: 10px;
}

.kcb-audio-player audio {
	width: 100%;
	accent-color: currentColor;
}

/*
 * portfolio-featured - a brand-new block type (no pre-existing
 * vendor CSS to match, unlike portfolio-grid, which reuses the theme's own
 * css/portfolio-grid.css), so its styling lives here instead of a new
 * theme stylesheet. See templates/portfolio-featured.php for the markup.
 */
.kcb-portfolio-featured {
	display: flex;
	flex-direction: column;
	gap: 20px;
}

.kcb-portfolio-featured__media img {
	display: block;
	width: 100%;
	height: auto;
}

.kcb-portfolio-featured__title {
	margin: 0 0 10px;
}

.kcb-portfolio-featured__excerpt {
	margin: 0 0 16px;
}

/*
 * tabs / accordion - deliberately thin: almost all real
 * styling comes from the theme's own already-enqueued-sitewide
 * css/jqueryui/custom.css + css/screen.css `.ui-tabs`/`.ui-accordion`
 * rules (confirmed present and loaded via functions.php's
 * pp_enqueue_front_page_scripts(), though never wired to the real jQuery
 * UI tabs/accordion widget JS anywhere in this theme - grepped, no
 * `.tabs(`/`.accordion(` init call exists). render_tabs()/render_accordion()
 * (blocks-layout.php) emit that same jQuery-UI-shaped markup/classnames on
 * purpose to inherit that CSS for free; only the show/hide interaction
 * itself is new vanilla JS (includes/render/blocks.js), since the real
 * widget JS dependency was confirmed dead/never-initialized, not an active
 * site-wide behavior to build on top of.
 */
.kcb-tabs__nav {
	display: flex;
	flex-wrap: wrap;
	list-style: none;
	margin: 0;
	padding: 0;
}

.kcb-tabs.kcb-tabs--vertical {
	display: flex;
	align-items: flex-start;
	gap: 20px;
}

.kcb-tabs.kcb-tabs--vertical.kcb-tabs--right {
	flex-direction: row-reverse;
}

.kcb-tabs--vertical .kcb-tabs__nav {
	flex-direction: column;
	flex: 0 0 auto;
}

.kcb-tabs--vertical .kcb-tabs__panels {
	flex: 1 1 auto;
	min-width: 0;
}

.kcb-tabs__nav button {
	background: none;
	border: 0;
	cursor: pointer;
	font: inherit;
	color: inherit;
}

/*
 * The docblock above this section
 * claims tabs/accordion "inherit [the vendor jQuery-UI] CSS for free" by
 * emitting the same classnames - true for accordion (kedynsierra_builder_
 * render_accordion()'s wrapper really does carry `ui-accordion`, matching
 * css/jqueryui/custom.css's `.ui-accordion .ui-accordion-content{display:
 * none}` selector), but FALSE for tabs: kedynsierra_builder_render_tabs()'s
 * wrapper only ever carries `kcb-tabs`, never `ui-tabs` - so custom.css's
 * `.ui-tabs .ui-tabs-hide{display:none !important}` selector never
 * matched, and every tab panel's content showed simultaneously regardless
 * of which tab was active. Confirmed live via getComputedStyle() on a real
 * rendered page. Fixed here with a self-contained rule instead of adding
 * `ui-tabs` to the wrapper - inheriting one specific vendor selector this
 * way is safer than opting the whole element into every other `.ui-tabs`-
 * scoped rule in a stylesheet this plugin doesn't own/control.
 */
.kcb-tabs__panel.ui-tabs-hide {
	display: none;
}

/*
 * Accordion collapse, self-contained (2026-08-24).
 *
 * The comment above notes that accordion, unlike tabs, really did inherit
 * its collapse behaviour from the vendor stylesheet - css/jqueryui/
 * custom.css's `.ui-accordion .ui-accordion-content{display:none}` - because
 * render_accordion()'s wrapper does carry `ui-accordion`.
 *
 * That made a block's core "only one panel open" behaviour depend on a
 * 20KB vendor stylesheet this plugin does not own, which the theme now
 * loads only on pages that actually contain one of these two blocks. Rather
 * than have this block silently unfold every panel if that gating is ever
 * wrong, the rules it genuinely needs are restated here against the
 * plugin's OWN classnames - exactly the reasoning applied to tabs above,
 * and for the same reason: inherit one vendor selector's effect without
 * opting into every other `.ui-accordion`-scoped rule in a stylesheet this
 * plugin doesn't control.
 *
 * These duplicate rather than replace the vendor rules; where both load,
 * they agree.
 */
.kcb-accordion__content {
	display: none;
}

.kcb-accordion__content.ui-accordion-content-active {
	display: block;
}

.kcb-accordion__header {
	display: flex;
	align-items: center;
	gap: 8px;
	/*
	 * The header is the whole control, and it had no padding of its own - so
	 * at <=767px it stood about 34px tall against the 44px minimum. Padding
	 * rather than a min-height so the hit area grows with the label instead of
	 * leaving the text floating in a taller empty box.
	 */
	padding: 11px 0;
}

/*
 * content-bg-image - kedynsierra_builder_render_content_bg_image()
 * (blocks-background-image.php). `--kcb-content-bg-image-min-height` is
 * only ever set inline when the `min_height` prop is > 0 (that function's
 * own docblock) - the fallback here covers the unset case (min_height: 0,
 * "auto"/content-driven), same custom-property-with-fallback pattern
 * .kcb-image-grid's `--kcb-grid-columns` already uses.
 * `--kcb-content-bg-image-padding` and `--kcb-content-bg-image-font-color`
 * follow the exact same shape, for the exact same reason: an untouched node
 * (the common case, sitewide, today) must render pixel-identical to before
 * either property existed, so the var() fallback is this rule's own
 * pre-existing hardcoded value, never a bare unfallbacked var().
 *
 * Mobile: min-height is deliberately NOT asserted below 768px - a fixed
 * banner-tall min-height calibrated for a wide desktop viewport would force
 * a much taller empty box on a narrow one relative to its actual text
 * content. `background-position` (inlined per-instance from `focal_x`/
 * `focal_y`) keeps applying at every width - see the render function's own docblock
 * for why that's a partial, not complete, answer to cropping on mobile.
 * `inner_padding` is dropped below 768px for the same reason as min-height
 * (see the @media rule below) - font_color has no such exception, a chosen
 * text color has no reason to change with viewport width.
 */
.kcb-content-bg-image {
	position: relative;
	display: flex;
	align-items: center;
	background-color: #1a1a1a;
	background-size: cover;
	background-repeat: no-repeat;
	min-height: var(--kcb-content-bg-image-min-height, auto);
	padding: var(--kcb-content-bg-image-padding, 60px 24px);
	overflow: hidden;
}

@media (max-width: 767px) {
	.kcb-content-bg-image {
		min-height: auto;
		/* Deliberately plain, not var(--kcb-content-bg-image-padding, ...) -
		 * an inner_padding calibrated for a wide desktop box (up to 400px)
		 * would swallow the whole text area on a narrow phone screen, same
		 * reasoning as min-height's own reset just above. The horizontal 20
		 * became 24 when this block started bleeding to the viewport edge
		 * below - text 20px off a phone's physical edge reads tight. */
		padding: 40px 24px;

		/*
		 * Full-bleed on phones (2026-08-26, Kedyn's call after seeing the
		 * measurements). These cards sit inside .standard_wrapper (80% wide)
		 * plus 20px of .page_content padding, so on a 375px phone the box
		 * measured just 260x359 - portrait, AR 0.72, against a 1.50 landscape
		 * photo. background-size:cover therefore showed only 48% of the image
		 * width (window 10.9-59.2%), which is what sliced a person off the
		 * right of the Contact page's "Seven Tepees" group shot. No focal_x
		 * could have fixed that: the four subjects span 14-92% of the frame
		 * shoulder-to-shoulder, and a 48% window cannot hold a 78% subject.
		 *
		 * Bleeding to the viewport fixes it twice over - it widens the box AND
		 * lets the heading wrap to fewer lines, so the box gets shorter too.
		 * Measured on a live 375px render: 260x359 (48% visible) becomes
		 * 375x250 - an AR of 1.50 matching the photo exactly, so 100% shows.
		 *
		 * Same negative-margin idiom, and the same caution, as
		 * .kcb-row--full-bleed above - margins alone, no width:100vw to
		 * double-count with. That rule's note about 50vw counting the
		 * scrollbar gutter applies here too and was measured rather than
		 * assumed: at 375, 600 and 767px a desktop browser draws this card
		 * ~15px wider than its content area, where it overhangs without
		 * scrolling (scrollWidth == clientWidth at all three, via the same
		 * probe that DID report non-zero for this site's two real overflow
		 * bugs earlier this week). A real phone reserves no scrollbar, so
		 * there the bleed is exact.
		 */
		margin-left: calc(-1 * (50vw - 50%));
		margin-right: calc(-1 * (50vw - 50%));
	}
}

.kcb-content-bg-image__overlay {
	position: absolute;
	inset: 0;
	pointer-events: none;
}

.kcb-content-bg-image__inner {
	position: relative;
	width: 100%;
	/* var(--measure-reading), not a private 720px - one prose measure
	   sitewide (Field Plan Phase 1, 2026-08-23). Fallback stays 720 for
	   pages without the theme's token (defensive; this block only ever
	   renders inside the theme today). */
	max-width: var(--measure-reading, 720px);
	margin: 0 auto;
	color: var(--kcb-content-bg-image-font-color, #fff);
}

.kcb-content-bg-image--align-left .kcb-content-bg-image__inner {
	text-align: left;
	margin-left: 0;
}

.kcb-content-bg-image--align-center .kcb-content-bg-image__inner {
	text-align: center;
}

.kcb-content-bg-image--align-right .kcb-content-bg-image__inner {
	text-align: right;
	margin-right: 0;
}

.kcb-content-bg-image__heading {
	margin: 0 0 0.3em 0;
	/* color:inherit is load-bearing, not tidiness: __inner above sets the
	 * block's white text color, but this heading is a real <h2>, and
	 * Kirki's generated global `h2 { color: #222222 }` element rule beats
	 * INHERITED color on the element itself - live-confirmed as a
	 * near-invisible dark-on-dark heading over this block's dark overlay.
	 * An explicit class-level `inherit` (0,1,0) outranks that element rule
	 * (0,0,1) and re-routes the heading back to __inner's color. */
	color: inherit;
}

.kcb-content-bg-image__subtitle {
	margin: 0 0 0.6em 0;
	opacity: 0.85;
}

.kcb-content-bg-image__body > *:last-child {
	margin-bottom: 0;
}

/*
 * image-half-bleed - kedynsierra_builder_render_
 * image_half_bleed() (blocks-background-image.php). The full-bleed edge is
 * only asserted at 768px+; below that this is a plain full-width image in
 * normal flow, same breakpoint every other multi-column type in this file
 * collapses at. See the render function's own docblock for the documented
 * limitation when this block sits inside a `row`/`freeform` container
 * instead of the theme's normal centered content column.
 */
.kcb-image-half-bleed {
	margin: 0;
}

.kcb-image-half-bleed__image {
	display: block;
	width: 100%;
	height: auto;
}

@media (min-width: 768px) {
	.kcb-image-half-bleed {
		width: 50%;
	}

	.kcb-image-half-bleed--bleed-right {
		margin-right: calc(-1 * (50vw - 50%));
	}

	.kcb-image-half-bleed--bleed-left {
		margin-left: calc(-1 * (50vw - 50%));
	}

	.kcb-image-half-bleed__image {
		width: 100%;
		height: 100%;
		object-fit: cover;
	}
}

/*
 * `.kcb-image-parallax` is already the complete, correct rendering with no
 * JS at all - a static, cover-fit background image. This is also the exact
 * fallback state under prefers-reduced-motion and on touch devices
 * (assets/js/image-parallax.js skips initializing Rellax in both cases and
 * never touches this element again), so there is deliberately no separate
 * ".is-reduced-motion"/no-JS branch here - the base rule IS that branch.
 */
/*
 * min-height is a FLOOR, not the real height - the real one is inline,
 * from the renderer (live) or the canvas type-render. It exists because
 * this element is a bare div whose only content is a background image:
 * the instant that inline height goes missing it collapses to 0px and
 * reads as a block that vanished, with nothing on screen to debug. A
 * floor turns a total disappearance into something visibly wrong.
 */
.kcb-image-parallax {
	min-height: 120px;
	position: relative;
	overflow: hidden;
	background-size: cover;
	background-position: center;
	background-repeat: no-repeat;
}

/*
 * Layout only - deliberately no `outline`/focus overrides here. Plain
 * <button> elements (templates/portfolio-slider.php) already get a real,
 * visible keyboard focus ring for free from css/screen.css's sitewide
 * `button:focus-visible` rule; a scoped rule here would risk becoming a
 * second, conflicting focus treatment instead of just reusing the one that
 * already exists site-wide.
 */
/*
 * Kedyn's call: no more reserved white band under the image for these
 * controls - they live inside the image itself now, overlaid near the
 * bottom edge, so the space after a slider is plain empty margin (an
 * actual separator) rather than a filled box. `.kcb-gallery-slider`/
 * `.kcb-portfolio-slider` are already `position: relative; overflow:
 * hidden` from the theme's own `.slider_wrapper` base rule (css/screen.css),
 * so no extra positioning context is needed here. z-index 3 clears both the
 * stacked layout's deck cards (0) and its .flexslider (1) below, so the
 * stacked-specific controls z-index override this file used to carry is
 * gone - this rule alone now wins paint order in every layout.
 */
.kcb-gallery-slider__controls,
.kcb-portfolio-slider__controls {
	display: flex;
	align-items: center;
	justify-content: center;
	gap: 12px;
	position: absolute;
	left: 50%;
	bottom: 16px;
	transform: translateX(-50%);
	z-index: 3;
}

/* 44x44px is the minimum practical tap-target size, so these controls are
 * sized to it exactly rather than to whatever the glyph needs. Both sliders
 * share one rule so their controls stay the same size as each other.
 * Translucent + blurred rather than a solid fill so they read as sitting
 * on the photo, not as a UI chrome bar - the backdrop-filter keeps the
 * glyph legible over both light and dark parts of whatever image is behind
 * it; the rgba fallback (browsers without backdrop-filter support) still
 * gives enough contrast on its own. */
.kcb-gallery-slider__controls button,
.kcb-portfolio-slider__controls button {
	background: rgba(0, 0, 0, 0.32);
	backdrop-filter: blur(6px);
	-webkit-backdrop-filter: blur(6px);
	border: 1px solid rgba(255, 255, 255, 0.65);
	border-radius: 50%;
	color: #fff;
	cursor: pointer;
	font: inherit;
	line-height: 1;
	width: 44px;
	height: 44px;
	transition: background-color 0.2s ease;
}

.kcb-gallery-slider__controls button:hover,
.kcb-portfolio-slider__controls button:hover {
	background: rgba(0, 0, 0, 0.5);
}

/*
 * People grid (testimonials/team) - one grid shape for both
 * variants (kedynsierra_builder_render_people_grid(), blocks-people.php),
 * matching .kcb-image-grid's own repeat(var(--kcb-grid-columns))/gap
 * mechanism exactly (same custom-property name, different class - the two
 * grids never coexist in the same element so there's no need to share a
 * class). Collapses to 1 column at the same 767px breakpoint every other
 * multi-column block here already uses (see the @media rule earlier in this
 * file) - the shared selector list there does not include this class
 * because this rule already declares its own single-column value at that
 * width below, no duplication needed.
 */
.kcb-people-grid {
	display: grid;
	grid-template-columns: repeat(var(--kcb-grid-columns, 3), minmax(0, 1fr));
	gap: 32px;
}

@media (max-width: 767px) {
	.kcb-people-grid {
		grid-template-columns: 1fr;
	}
}

.kcb-people__item {
	margin: 0;
	display: flex;
	flex-direction: column;
	align-items: flex-start;
	gap: 12px;
}

.kcb-people__avatar {
	width: 64px;
	height: 64px;
	border-radius: 50%;
	object-fit: cover;
	flex: none;
}

.kcb-people__name {
	font-weight: 600;
	font-style: normal;
	margin: 0;
}

.kcb-people__role {
	display: block;
	font-weight: 400;
	opacity: 0.75;
	font-size: 0.9em;
}

/* Testimonial variant - blockquote/figcaption, same left-rule/italic
 * treatment as .kcb-pull-quote above so a testimonial and a pull-quote read
 * as the same visual family. */
.kcb-people__item--testimonial .kcb-people__quote {
	margin: 0;
	padding: 0 0 0 20px;
	border-left: 4px solid currentColor;
	font-style: italic;
	line-height: 1.5;
}

.kcb-people__item--testimonial .kcb-people__attribution {
	display: flex;
	flex-wrap: wrap;
	gap: 6px;
	font-style: normal;
}

.kcb-people__rating {
	font-size: 1.1em;
	letter-spacing: 2px;
	color: currentColor;
}

/* Team variant - a plain bio card, no blockquote framing. */
.kcb-people__item--team .kcb-people__bio {
	margin: 0;
	line-height: 1.5;
}

/*
 * contact-form (includes/blocks/blocks-forms.php). Plain block-level
 * stacking, not a grid - a contact form reading top-to-bottom in document
 * order is also its natural tab order, and this plugin has no design-token
 * spacing scale to reach for yet (same gap noted at the top of this file).
 */
.kcb-contact-form__field {
	margin: 0 0 16px 0;
}

.kcb-contact-form__field label {
	display: block;
	margin: 0 0 4px 0;
}

.kcb-contact-form__field input[type="text"],
.kcb-contact-form__field input[type="email"],
.kcb-contact-form__field textarea {
	width: 100%;
	box-sizing: border-box;
	font: inherit;
}

.kcb-contact-form__field--consent {
	display: flex;
	align-items: flex-start;
	gap: 8px;
	flex-wrap: wrap;
}

.kcb-contact-form__field--consent label {
	display: inline;
	margin: 0;
}

.kcb-contact-form__error {
	display: block;
	color: #b3261e;
	font-size: 13px;
	margin-top: 4px;
}

.kcb-contact-form__error:empty {
	display: none;
}

.kcb-contact-form__response:empty {
	display: none;
}

.kcb-contact-form__response {
	margin: 16px 0;
	padding: 12px;
	border-radius: 4px;
}

.kcb-contact-form__response--success {
	background: #e6f4ea;
	color: #1e4620;
}

.kcb-contact-form__response--error {
	background: #fce8e6;
	color: #8c1d18;
}

/*
 * Honeypot - visually hidden but NOT display:none/visibility:hidden, so it
 * stays in normal layout flow the way a real spam bot's own heuristics
 * expect a genuine field to (some bots specifically skip display:none/
 * visibility:hidden fields as an already-known trap pattern; this doesn't
 * try to out-clever that, it just doesn't rely on it). Real users never see
 * or reach it: aria-hidden on the wrapper plus tabindex="-1" on the input
 * itself (blocks-forms.php's render callback) removes it from both the
 * accessibility tree and the keyboard tab order. Same clip-to-1px technique
 * as the theme's own .pm-visually-hidden (photome-child/style.css) - not
 * reused directly since this plugin's CSS doesn't otherwise depend on
 * theme-specific classnames anywhere else in this file.
 */
.kcb-contact-form__hp {
	position: absolute;
	width: 1px;
	height: 1px;
	padding: 0;
	margin: -1px;
	overflow: hidden;
	clip: rect(0, 0, 0, 0);
	white-space: nowrap;
	border: 0;
}

/*
 * Visible keyboard focus for this block's own <textarea> (the message
 * field) - the theme's global two-tone ring (photome-child/style.css)
 * already covers `input`/`button`/`a` sitewide but not `textarea`, and this
 * is the only textarea this plugin's catalog renders as a real user-
 * focusable form control. Same two values (2px white outline + 2px black
 * box-shadow) for visual consistency with every other focusable element on
 * the page, not a new ring design - see that file's own docblock for the
 * contrast reasoning behind the two colors.
 */
.kcb-contact-form__field textarea:focus-visible {
	outline: 2px solid #fff;
	outline-offset: 2px;
	box-shadow: 0 0 0 2px #000;
}

/*
 * Post Listing - kedynsierra_builder_render_post_listing()
 * (blocks-listing.php). Each card is a single real <a> wrapping the
 * thumbnail + title + date (not a wrapper div plus a separate "read more"
 * link) - one link target per card, and a:focus-visible's existing sitewide
 * rule (css/screen.css) already gives it a visible keyboard focus ring with
 * no block-specific rule needed here.
 */
.kcb-listing {
	display: grid;
	grid-template-columns: repeat(var(--kcb-grid-columns, 3), minmax(0, 1fr));
	gap: 28px;
}

.kcb-listing__item {
	display: block;
	color: inherit;
	text-decoration: none;
}

.kcb-listing__thumb {
	display: block;
	width: 100%;
	height: auto;
	margin: 0 0 12px;
}

.kcb-listing__body {
	display: block;
}

.kcb-listing__title {
	display: block;
	font-weight: 600;
	text-decoration: underline;
	text-underline-offset: 3px;
}

.kcb-listing__date {
	display: block;
	font-size: 13px;
	opacity: 0.75;
	margin-top: 4px;
}

.kcb-listing__more {
	display: inline-block;
	margin-top: 20px;
}

/*
 * Empty state (docs/specs brief - "first-class case, not an afterthought").
 * This site has zero published `post`s today, so this is the common render
 * for post_type=post, not a rare edge - styled to read as an intentional
 * placeholder, not a broken/missing block.
 */
.kcb-listing--empty {
	padding: 32px 20px;
	text-align: center;
	border: 1px dashed rgba(0, 0, 0, 0.15);
	border-radius: 8px;
}

.kcb-listing__empty-message {
	margin: 0;
	opacity: 0.75;
}

@media (max-width: 767px) {
	.kcb-listing {
		grid-template-columns: 1fr;
	}
}

/*
 * Pricing table + stats/counter strip - share one layout
 * primitive, `.kcb-card-row` (blocks-marketing.php's own docblock explains
 * why it's a rename/reuse of `.kcb-image-grid`/`.kcb-row`'s identical grid
 * mechanism rather than a new one): CSS Grid, a `--kcb-card-row-columns`
 * custom property set per-instance from the item count, 20px gap, and its own
 * 767px collapse immediately below (NOT the shared list near the top of this
 * file - this rule is declared after it, so that one never won).
 * `.kcb-card-row__item` only
 * carries the one thing both block types' items actually share (centered
 * text) - everything else is block-specific below.
 */
.kcb-card-row {
	display: grid;
	/* minmax(0, 1fr) on the base rule too - same reason as .kcb-image-grid
	 * and .kcb-row above, and the same reason this block's own mobile
	 * collapse below already gives. (A7) */
	grid-template-columns: repeat(var(--kcb-card-row-columns, 3), minmax(0, 1fr));
	gap: 20px;
}

/*
 * Mobile collapse, placed AFTER the base rule rather than in the shared list
 * near the top of this file - see that block's docblock for the source-order
 * trap that kept this from ever firing.
 *
 * `minmax(0, 1fr)` rather than a bare `1fr` for the stats strip: `1fr` means
 * `minmax(auto, 1fr)`, and that `auto` floor is min-content, so a track can
 * refuse to shrink below its longest unbreakable label and push the grid wider
 * than its container - which is exactly how this overflowed in the first
 * place. `minmax(0, ...)` lets the track shrink and keeps the overflow inside
 * the text, never in the layout.
 *
 * Pricing tiers stack to one column (a tier is a paragraph-sized card), but a
 * stats strip is four two-word figures: 2x2 reads far better on a phone than a
 * 4-high stack. Measured on the live strip at 375px: two 147.5px tracks, and
 * the longest label ("Miles Walked") is 90px inside a 108px content box.
 *
 * The one-column floor is 359px, not 480px, precisely so ordinary phones
 * (375px and up) get the 2x2 rather than a 4-high stack. 359 is where the
 * measurement actually runs out: content width per track is
 * (vw - 60 outer - 20 gap) / 2 - 40 padding, which drops under that 90px label
 * at about 340px, so the floor sits just above it with room to spare.
 *
 * Written as `.kcb-card-row.kcb-stats-strip` (0,2,0) so it beats the
 * single-class rule above regardless of order.
 */
@media (max-width: 767px) {
	.kcb-card-row {
		grid-template-columns: 1fr;
	}

	.kcb-card-row.kcb-stats-strip {
		grid-template-columns: repeat(2, minmax(0, 1fr));
	}
}

@media (max-width: 359px) {
	.kcb-card-row.kcb-stats-strip {
		grid-template-columns: minmax(0, 1fr);
	}
}

.kcb-card-row__item {
	text-align: center;
}

.kcb-pricing-table__item {
	display: flex;
	flex-direction: column;
	gap: 12px;
	padding: 30px 24px;
	border: 1px solid #ddd;
	border-radius: 4px;
}

/*
 * Border-width bump only, no colour - a photographer's pricing tiers get a
 * real visual highlight without this block defining a background/text-
 * colour field of its own (blocks-marketing.php's own docblock: that's
 * the universal overlay's job to provide, not this block's to duplicate).
 */
.kcb-pricing-table__item--featured {
	border-width: 2px;
}

.kcb-pricing-table__badge {
	align-self: center;
	font-size: 12px;
	font-weight: bold;
	letter-spacing: 0.05em;
	text-transform: uppercase;
}

.kcb-pricing-table__name {
	margin: 0;
}

.kcb-pricing-table__price {
	margin: 0;
	font-size: 28px;
	font-weight: bold;
}

.kcb-pricing-table__features {
	margin: 0;
	padding: 0;
	list-style: none;
	text-align: left;
}

.kcb-pricing-table__features li {
	padding: 6px 0;
	border-top: 1px solid #eee;
}

.kcb-pricing-table__features li:first-child {
	border-top: 0;
}

.kcb-pricing-table__cta {
	margin-top: auto;
	align-self: center;
}

.kcb-stats-strip__item {
	display: flex;
	flex-direction: column;
	align-items: center;
	gap: 4px;
	padding: 20px;
}

.kcb-stats-strip__value {
	font-size: 40px;
	font-weight: bold;
	line-height: 1.1;
}

.kcb-stats-strip__label {
	font-size: 14px;
}

/*
 * The theme's own `.slider_wrapper { overflow: hidden; }` (css/screen.css) is
 * a generic legacy rule every FlexSlider instance on the site carries, meant
 * to hide the non-current slide while it's off to the side mid-transition.
 * Coverflow/scattered need the opposite on their two neighbour cards, which
 * are meant to visibly peek past the wrapper's own edge - the combined
 * selector below (both classes already on the same element) outweighs the
 * theme rule's single-class specificity regardless of stylesheet order.
 */
.kcb-gallery-slider--coverflow.slider_wrapper,
.kcb-portfolio-slider--coverflow.slider_wrapper,
.kcb-gallery-slider--scattered.slider_wrapper,
.kcb-portfolio-slider--scattered.slider_wrapper {
	overflow: visible;
}

/*
 * Coverflow layout (gallery-slider/portfolio-slider `layout: 'coverflow'`).
 * Built as a CSS approximation of a 3-card carousel, not a real one: a true
 * side-by-side carousel needs FlexSlider's `itemWidth`-driven layout mode,
 * which only activates when `animation` isn't `"fade"` - switching
 * `animation` was offered and turned down, since it would make coverflow
 * crossfade unlike every other slider on the site. What's built here instead
 * works with FlexSlider's default single-item stacking (every `<li>` sits at
 * the same position, `margin-right: -100%`) rather than against it.
 *
 * Two things FlexSlider owns on every `<li>`, neither of them `transform`:
 *
 * - Opacity: written inline on every transition (`.css({opacity: ...})`),
 *   which beats any stylesheet declaration without `!important`. Only the
 *   active slide gets inline opacity 1; every other slide, including the
 *   two we want visible as flanking cards, gets inline opacity 0. The
 *   neighbour rules below reclaim opacity with `!important` - noted there,
 *   not hidden in a bare declaration - and the actual dimmed-preview look
 *   lives on the slide's own direct children instead (the link, and the
 *   gallery variant's caption - two siblings, not one, so both need to dim
 *   together), which FlexSlider never touches at all.
 * - Position/width/margin: also inline, which is why every `<li>` shares one
 *   spot rather than laying out side by side.
 *
 * `transform` is free - FlexSlider never sets it - so scale and the
 * flanking offset both live there, on the `<li>` itself.
 *
 * `.kcb-slide-active`/`.kcb-slide-prev-neighbor`/`.kcb-slide-next-neighbor`
 * are ours, set by the two script-*-flexslider.php files' `start`/`after`
 * callbacks against `slider.currentSlide`/`slider.animatingTo` with wrap-
 * around. FlexSlider's own `flex-active-slide` also fires here (it skips
 * that only when `itemWidth` is set, which this layout does not set) -
 * nothing here reads it, so the two coexist without conflict.
 */
.kcb-gallery-slider--coverflow .flexslider .slides li,
.kcb-portfolio-slider--coverflow .flexslider .slides li {
	transform: scale(0.6);
	transition: transform 0.35s ease;
	pointer-events: none;
}

.kcb-gallery-slider--coverflow .flexslider .slides li > *,
.kcb-portfolio-slider--coverflow .flexslider .slides li > * {
	opacity: 1;
	transition: opacity 0.35s ease;
}

.kcb-gallery-slider--coverflow .flexslider .slides li.kcb-slide-active,
.kcb-portfolio-slider--coverflow .flexslider .slides li.kcb-slide-active {
	transform: scale(1);
	pointer-events: auto;
}

/*
 * Flanking neighbours only render at widths that can actually fit them
 * beside the active card without the peeking cards reading as clutter or
 * overlapping page content below - a fresh judgment about this CSS effect,
 * not inherited from the old JS carousel-track-width gate. Below this width
 * coverflow/scattered fall back to a plain single active card, same as
 * `layout: 'default'`.
 */
@media (min-width: 768px) {
	.kcb-gallery-slider--coverflow .flexslider .slides li.kcb-slide-prev-neighbor,
	.kcb-gallery-slider--coverflow .flexslider .slides li.kcb-slide-next-neighbor,
	.kcb-portfolio-slider--coverflow .flexslider .slides li.kcb-slide-prev-neighbor,
	.kcb-portfolio-slider--coverflow .flexslider .slides li.kcb-slide-next-neighbor {
		opacity: 1 !important; /* reclaims the slide from FlexSlider's own inline opacity: 0 - see comment above */
		pointer-events: none;
	}

	.kcb-gallery-slider--coverflow .flexslider .slides li.kcb-slide-prev-neighbor,
	.kcb-portfolio-slider--coverflow .flexslider .slides li.kcb-slide-prev-neighbor {
		transform: translateX(-62%) scale(0.82);
	}

	.kcb-gallery-slider--coverflow .flexslider .slides li.kcb-slide-next-neighbor,
	.kcb-portfolio-slider--coverflow .flexslider .slides li.kcb-slide-next-neighbor {
		transform: translateX(62%) scale(0.82);
	}

	.kcb-gallery-slider--coverflow .flexslider .slides li.kcb-slide-prev-neighbor > *,
	.kcb-gallery-slider--coverflow .flexslider .slides li.kcb-slide-next-neighbor > *,
	.kcb-portfolio-slider--coverflow .flexslider .slides li.kcb-slide-prev-neighbor > *,
	.kcb-portfolio-slider--coverflow .flexslider .slides li.kcb-slide-next-neighbor > * {
		opacity: 0.45;
	}
}

/*
 * Timed enter/exit (gallery-slider/portfolio-slider `transition: 'timed'`)
 * - same brief as above, section 2: applying enter/exit
 * transition timing to slides, not the full RevSlider-style per-layer
 * keyframe UI (docs/specs/REVSLIDER-REPLACEMENT-SPEC.md, a separate,
 * already-in-progress initiative - deliberately not conflated here).
 *
 * `--kcb-enter-*`/`--kcb-exit-*` are set once, at init, by the two script-
 * *-flexslider.php files above (from the block's own sanitized `enter_*` and
 * `exit_*` props) - never recomputed per transition. `.kcb-slide-exiting` is
 * added to the OUTGOING slide and `.kcb-slide-entering` to the INCOMING one
 * on FlexSlider's own `before` callback (`.kcb-slide-enter-from` is a one-
 * frame-only starting state so the incoming slide's own transition has a
 * "from" to animate away from - see the JS for the double-rAF that swaps it
 * for `.kcb-slide-entering`), both removed again on `after`. Applied to
 * `opacity`/`transform` only - FlexSlider's own "slide" animation (the base
 * config both scripts switch to for this mode) moves the `<ul>` via `left`/
 * `transform: translate3d`, never touching an individual `<li>`'s own
 * opacity/transform, so these two layers never fight the same property.
 *
 * The base "resting state" rule below is `:not()`-scoped away from
 * `--coverflow`/`--scattered` on purpose: `layout` and `transition` are
 * independent props, so a slider can be e.g. `coverflow` AND `timed` at
 * once, and this rule's plain `opacity:1` would otherwise have equal
 * specificity to coverflow's own muted-flanking-card base rule above and
 * silently cancel it (or scattered's tilt below) purely by whichever
 * happened to sit later in this file - a source-order accident, not a
 * deliberate precedence. Excluding those two layouts here means each
 * layout's own base rule is the only one that can ever apply to its
 * slides, independent of file order.
 */
/*
 * (FlexSlider retirement stage 2, 2026-08-23: the FlexSlider-era timed
 * rules that lived here - written for FlexSlider's "slide" mode, where
 * every <li> sat in a horizontal row at opacity 1 - are gone. Their base
 * `li { opacity: 1 }` rule would out-specify the first-party engine's own
 * absolute-stacked `opacity: 0` resting state and paint every slide on
 * top of each other. The choreography now lives in the engine section at
 * the bottom of this file, gated on `[data-transition="timed"]`, with end
 * states adapted to stacked crossfade - same class names, same
 * `--kcb-enter-*`/`--kcb-exit-*` custom properties, now set by
 * kcb-slider.js from wrapper data- attributes.)
 */

/*
 * `prefers-reduced-motion: reduce` suppresses the motion in both features
 * above - same real accessibility requirement (not optional polish) that
 * js/gallery-reveal.js and assets/js/image-parallax.js already handle in
 * JS. The two script-*-flexslider.php files skip attaching the timed
 * before/after choreography entirely under reduced motion (so `.kcb-slide-
 * entering`/`.kcb-slide-exiting` never even get added there) - this rule is
 * the CSS-side backstop for coverflow's own always-present scale/translate
 * transition (a layout state, not JS-gated, since the flanking-card LOOK
 * itself isn't motion - only the eased transition between slides is).
 */
@media (prefers-reduced-motion: reduce) {
	.kcb-gallery-slider--coverflow .flexslider .slides li,
	.kcb-portfolio-slider--coverflow .flexslider .slides li,
	.kcb-gallery-slider--coverflow .flexslider .slides li > *,
	.kcb-portfolio-slider--coverflow .flexslider .slides li > *,
	.kcb-gallery-slider--timed .flexslider .slides li,
	.kcb-portfolio-slider--timed .flexslider .slides li,
	.kcb-gallery-slider--timed .flexslider .slides li.kcb-slide-exiting,
	.kcb-portfolio-slider--timed .flexslider .slides li.kcb-slide-exiting,
	.kcb-gallery-slider--timed .flexslider .slides li.kcb-slide-entering,
	.kcb-portfolio-slider--timed .flexslider .slides li.kcb-slide-entering {
		transition: none;
	}
}

/*
 * Scattered layout (gallery-slider/portfolio-slider `layout: 'scattered'`):
 * "photo-journal" cards at slightly different tilt angles, active card gets
 * a dashed border overlay and an index counter. Shares coverflow's flanking
 * mechanism above (same stacked `<li>`s, same active/prev/next marker
 * classes, same opacity-lives-on-the-child reasoning) with its own tilt
 * layered on top - each state's rule below carries the FULL transform for
 * that state (translate + scale + rotate together), since only one
 * `transform` declaration can ever win per element.
 *
 * Tilt is keyed to role (active/prev/next), not DOM position: under a real
 * FlexSlider carousel, "3 consecutive DOM children" and "3 currently visible
 * cards" were the same thing, so alternating by position made sense. Here
 * every slide always sits at the same stacked position and only the marker
 * classes say which role a card is playing, so a position-based selector
 * would tilt the wrong cards as soon as the active slide changes. 3 fixed
 * angles, one per role - deliberately not random, so the look stays
 * identical across renders/screenshots rather than reshuffling on reload.
 *
 * The source doc's dashed curved connector lines between cards (suggesting
 * a travel route) are NOT built - see this brief's commit message.
 */
.kcb-gallery-slider--scattered .flexslider .slides li,
.kcb-portfolio-slider--scattered .flexslider .slides li {
	transform: scale(0.6) rotate(-3deg);
	transition: transform 0.35s ease;
	pointer-events: none;
}

.kcb-gallery-slider--scattered .flexslider .slides li > *,
.kcb-portfolio-slider--scattered .flexslider .slides li > * {
	opacity: 1;
	transition: opacity 0.35s ease;
}

.kcb-gallery-slider--scattered .flexslider .slides li.kcb-slide-active,
.kcb-portfolio-slider--scattered .flexslider .slides li.kcb-slide-active {
	transform: scale(1) rotate(0deg);
	outline: 2px dashed rgba(0, 0, 0, 0.6);
	outline-offset: 6px;
	pointer-events: auto;
}

@media (min-width: 768px) {
	.kcb-gallery-slider--scattered .flexslider .slides li.kcb-slide-prev-neighbor,
	.kcb-gallery-slider--scattered .flexslider .slides li.kcb-slide-next-neighbor,
	.kcb-portfolio-slider--scattered .flexslider .slides li.kcb-slide-prev-neighbor,
	.kcb-portfolio-slider--scattered .flexslider .slides li.kcb-slide-next-neighbor {
		opacity: 1 !important; /* reclaims the slide from FlexSlider's own inline opacity: 0 - see coverflow comment above */
		pointer-events: none;
	}

	.kcb-gallery-slider--scattered .flexslider .slides li.kcb-slide-prev-neighbor,
	.kcb-portfolio-slider--scattered .flexslider .slides li.kcb-slide-prev-neighbor {
		transform: translateX(-62%) scale(0.82) rotate(-3deg);
	}

	.kcb-gallery-slider--scattered .flexslider .slides li.kcb-slide-next-neighbor,
	.kcb-portfolio-slider--scattered .flexslider .slides li.kcb-slide-next-neighbor {
		transform: translateX(62%) scale(0.82) rotate(2deg);
	}

	.kcb-gallery-slider--scattered .flexslider .slides li.kcb-slide-prev-neighbor > *,
	.kcb-gallery-slider--scattered .flexslider .slides li.kcb-slide-next-neighbor > *,
	.kcb-portfolio-slider--scattered .flexslider .slides li.kcb-slide-prev-neighbor > *,
	.kcb-portfolio-slider--scattered .flexslider .slides li.kcb-slide-next-neighbor > * {
		opacity: 0.45;
	}
}

/*
 * Decorative index counter ("01 - 05") - server-rendered by
 * kedynsierra_builder_render_gallery_slider() (blocks-media.php) and by
 * templates/portfolio-slider.php, updated on slide change by each block's own
 * `before` hook (templates/script-gallery-flexslider.php,
 * templates/script-portfolio-slider-flexslider.php).
 *
 * Both slider templates emit the counter markup, so this rule carries the
 * same two-selector shape every other rule in this section uses.
 */
.kcb-gallery-slider--scattered,
.kcb-portfolio-slider--scattered {
	position: relative;
}

/*
 * Every slider gets the counter now (2026-08-24 gallery pass), so it can no
 * longer sit 28px BELOW the box on a `position: relative` that only the
 * scattered layout set - on the default layout that would have dropped it
 * into the gap above the mosaic grid, unanchored. It's an overlay pill in the
 * bottom-right of the photo instead, borrowing the dark translucent language
 * of the slider's own prev/next/pause controls so the two read as one set of
 * chrome. z-index 4 clears the engine's slide stack (slides use 1-3).
 */
.slider_wrapper[data-kcb-slider] {
	position: relative;
}

/*
 * Slide-position live region (kcb-slider.js announceSlide(), 2026-08-24).
 *
 * The element also carries the theme's .pm-visually-hidden, but this rule is
 * deliberately NOT relying on it: that class lives in the theme's style.css,
 * and if this plugin's markup ever renders where that stylesheet does not
 * (the canvas preview, a future theme change), an unstyled live region would
 * paint the literal text "Slide 3 of 12" into the middle of the gallery.
 * Clip-based rather than display:none - a display:none region is not
 * announced at all, which would defeat the entire point of it.
 */
.kcb-gallery-slider__live {
	position: absolute;
	width: 1px;
	height: 1px;
	margin: -1px;
	padding: 0;
	overflow: hidden;
	clip: rect(0 0 0 0);
	clip-path: inset(50%);
	white-space: nowrap;
	border: 0;
}

.kcb-gallery-slider__counter,
.kcb-portfolio-slider__counter {
	position: absolute;
	right: 16px;
	bottom: 16px;
	z-index: 4;
	padding: 6px 12px;
	border-radius: 999px;
	background: rgba(0, 0, 0, 0.5);
	backdrop-filter: blur(6px);
	-webkit-backdrop-filter: blur(6px);
	color: #fff;
	font-size: 11px;
	line-height: 1;
	font-variant-numeric: tabular-nums;
	letter-spacing: 0.08em;
	/* Pure readout - never swallow a click meant for the photo beneath it. */
	pointer-events: none;
}

/*
 * The slider region is focusable now (see the tabindex in
 * kedynsierra_builder_slider_engine_attrs), so it needs a visible focus state
 * - without one, a keyboard visitor lands on a large silent box. Offset
 * inward because the wrapper is flush to the photo edge.
 */
.slider_wrapper[data-kcb-slider]:focus-visible {
	outline: 2px solid var(--color-accent, #92573b);
	outline-offset: -4px;
}

@media (max-width: 480px) {
	.kcb-gallery-slider__counter,
	.kcb-portfolio-slider__counter {
		right: 10px;
		bottom: 10px;
		padding: 5px 10px;
		font-size: 10px;
	}
}

/*
 * Stacked layout (gallery-slider/portfolio-slider `layout: 'stacked'`,
 * pattern 2 of the same source doc: "fanned deck," a
 * featured image with 2-3 cards visible stacked behind it at slight offsets
 * and rotations). Pure CSS on the wrapper itself, not the individual slides
 * - the source doc calls this "cheap to fake... no real carousel logic
 * needed," confirmed here: no FlexSlider option changes at all, matching
 * the coverflow/scattered branches above. `isolation: isolate` scopes the
 * two pseudo-cards' stacking context to this wrapper only, so `z-index: 0`
 * below can't be reordered by anything else on the page.
 */
.kcb-gallery-slider--stacked,
.kcb-portfolio-slider--stacked {
	position: relative;
	isolation: isolate;
}

.kcb-gallery-slider--stacked .flexslider,
.kcb-portfolio-slider--stacked .flexslider {
	position: relative;
	z-index: 1;
	/* Fade-mode slides taller than the JS-managed viewport height bleed past
	 * this box and, being positioned content, paint over the static controls
	 * row below regardless of DOM order. Clip at the viewport instead: in the
	 * healthy state the active slide fits exactly and this never engages; in
	 * a degraded state (stale height) the photo crops instead of burying the
	 * buttons. The deck pseudo-cards are unaffected - they belong to the
	 * wrapper, not this element. */
	overflow: hidden;
}

.kcb-gallery-slider--stacked::before,
.kcb-gallery-slider--stacked::after,
.kcb-portfolio-slider--stacked::before,
.kcb-portfolio-slider--stacked::after {
	content: '';
	position: absolute;
	/* Full wrapper again, framing the whole image - there's no separate
	 * controls row to leave clear anymore, the buttons overlay the image
	 * itself at z-index 3 (this file's __controls rule), well above these
	 * cards (z-index 0) and .flexslider (z-index 1) regardless of how far
	 * the translate/rotate offsets below dip. */
	inset: 0;
	z-index: 0;
	/* Tokenised 2026-08-24: these read as sheets of paper stacked behind the
	   print, which is right on a light page and blinding on a dark one -
	   measured as pure rgb(255,255,255) against the darkroom ground. The
	   defaults preserve the light-page look exactly; darkroom.css supplies
	   dark values. */
	background: var(--kcb-deck-card, #fff);
	border: 1px solid var(--kcb-deck-card-border, rgba(0, 0, 0, 0.1));
	border-radius: 4px;
}

.kcb-gallery-slider--stacked::before,
.kcb-portfolio-slider--stacked::before {
	transform: translate(10px, 10px) rotate(-3deg);
}

.kcb-gallery-slider--stacked::after,
.kcb-portfolio-slider--stacked::after {
	transform: translate(18px, 18px) rotate(3deg);
	opacity: 0.7;
}

/*
 * `prefers-reduced-motion: reduce` suppresses the scattered tilt the same
 * way the coverflow/timed backstop above does - the tilt/active-border
 * treatment itself is a static LOOK (not JS-gated, same reasoning as
 * coverflow's own note above), only the transition between angles is
 * motion. No equivalent rule needed for `stacked`: its two pseudo-cards are
 * static from the start (position/rotation set once, never transitioned),
 * so there is no motion for this media query to suppress.
 */
@media (prefers-reduced-motion: reduce) {
	.kcb-gallery-slider--scattered .flexslider .slides li,
	.kcb-portfolio-slider--scattered .flexslider .slides li,
	.kcb-gallery-slider--scattered .flexslider .slides li > *,
	.kcb-portfolio-slider--scattered .flexslider .slides li > *,
	.kcb-gallery-slider--scattered .flexslider .slides li.kcb-slide-active,
	.kcb-portfolio-slider--scattered .flexslider .slides li.kcb-slide-active {
		transition: none;
	}
}

/* ============================================================================
   ANIMATE - per-block entrance animation runtime
   ============================================================================
   Targets the `.kcb-animate` div kedynsierra_builder_wrap_animation_props()
   (includes/schema/animation-props.php) emits ONLY for a node whose
   `anim_effect` isn't 'none' - see that function's own docblock for why this
   is a separate nested div rather than added to `.kcb-style-wrap`'s existing
   one (an inline-style specificity conflict this section's class-driven
   opacity/transform would otherwise always lose).

   THE NO-JS/JS-FAILS INVARIANT, and how these rules keep it true:
   `.kcb-animate[data-kcb-animate-effect="..."]` ALONE - the attribute
   selector with no class - sets nothing here. The "hidden pre-state"
   (opacity:0 + an offset transform) only exists under the COMPOUND selector
   `.kcb-animate.kcb-animate-init[data-kcb-animate-effect="..."]`, and
   `.kcb-animate-init` is a class only assets/js/animate-blocks.js ever adds,
   synchronously, right before arming an IntersectionObserver or an
   on-load reveal. If that script never loads, throws, or is blocked, no
   node anywhere on the page ever gains `.kcb-animate-init` - every
   `.kcb-animate` div is left exactly as blank-slate-visible as if this
   whole section of CSS didn't exist. This is the load-bearing reason the
   hidden state is expressed as a compound class+attribute selector instead
   of the attribute selector alone.

   Duration/delay/easing are read from the CSS custom properties
   animation-props.php's wrap function sets inline per node
   (--kcb-anim-duration/--kcb-anim-delay/--kcb-anim-easing) - the var()
   fallbacks below (600ms/0ms/ease-out) only matter if that inline style
   were ever missing, which in practice it never is whenever `.kcb-animate`
   exists at all (both come from the same function call).

   Parallax is deliberately NOT part of the init/in reveal system above -
   it's continuous, scroll-position-driven motion (assets/js/animate-blocks.js),
   not a one-time reveal, so it has no hidden pre-state to guard. It relies
   on its own JS-side matchMedia checks instead (prefers-reduced-motion,
   plus a pointer:coarse touch/battery gate - see that script's own
   early-return docblock) and a dedicated
   CSS backstop near the bottom of this section: the JS checks only run
   ONCE, at script load, so a prefers-reduced-motion change made mid-session
   (OS setting toggled without a page reload) would otherwise go unnoticed
   for the rest of that page view - unlike an `@media` query, which
   re-evaluates live.

   `.kcb-animate-ready` is a second gate layered
   on top of `.kcb-animate-init` above, added by the runtime only after the
   hidden pre-state has already been committed as its own rendered frame
   (see assets/js/animate-blocks.js's own top docblock for the full FOUC
   reasoning - this script is enqueued in the footer, so an above-the-fold
   block can otherwise be painted visible, briefly, before the runtime ever
   runs). `.kcb-animate-init` alone now sets ONLY the hidden opacity/
   transform, no `transition` - so the very first time it lands, the
   element snaps to hidden instantly. The `transition` declaration itself
   lives on `.kcb-animate.kcb-animate-ready[data-kcb-animate-effect]`
   below, so it only ever applies to a change that happens AFTER that snap,
   never to the snap itself.

   Note that parallax is exempted from the front-end FOUC head gate too
   (includes/render/content-filter.php's
   kedynsierra_builder_print_animate_head_gate(), whose selector carries an
   explicit `:not([data-kcb-animate-effect="parallax"])`) - same reasoning
   as this paragraph's first line: there is no hidden pre-state, so hiding
   a parallax node before the runtime starts would suppress content that
   was always meant to be visible rather than prevent a flash. */

.kcb-animate {
	--kcb-anim-duration: 600ms;
	--kcb-anim-delay: 0ms;
	--kcb-anim-easing: ease-out;
}

.kcb-animate.kcb-animate-init[data-kcb-animate-effect] {
	opacity: 0;
}

/* Transition lives here, not on `.kcb-animate-init` above (,
   quality pass) - see this section's own top docblock ("`.kcb-animate-ready`")
   for why: `.kcb-animate-ready` is only added by the runtime once
   `.kcb-animate-init`'s hidden state has already been committed as its own
   rendered frame, so this transition only ever animates the LATER,
   intentional reveal - never the initial snap-to-hidden, which stays
   instant and unanimated on every browser regardless of how long the
   runtime script takes to first run. */
.kcb-animate.kcb-animate-ready[data-kcb-animate-effect] {
	/* clip-path/filter added to this list ahead of incoming
	   clip-path- and filter-based effects - harmless no-ops for every
	   current effect, none of which change either property. ANY new effect
	   that introduces yet another animated property must extend this same
	   shared list, not declare its own separate `transition` - a transition
	   declared anywhere other than under this class gate (e.g. back on
	   `.kcb-animate-init` above) reintroduces the exact FOUC bug this gate
	   exists to prevent, regardless of which property it's for. See this
	   section's own top docblock ("`.kcb-animate-ready`") for the full
	   reasoning. */
	transition: opacity var(--kcb-anim-duration) var(--kcb-anim-easing) var(--kcb-anim-delay),
		transform var(--kcb-anim-duration) var(--kcb-anim-easing) var(--kcb-anim-delay),
		clip-path var(--kcb-anim-duration) var(--kcb-anim-easing) var(--kcb-anim-delay),
		filter var(--kcb-anim-duration) var(--kcb-anim-easing) var(--kcb-anim-delay);
}

.kcb-animate.kcb-animate-init[data-kcb-animate-effect="slide-up"] {
	transform: translateY(40px);
}

.kcb-animate.kcb-animate-init[data-kcb-animate-effect="slide-left"] {
	transform: translateX(40px);
}

.kcb-animate.kcb-animate-init[data-kcb-animate-effect="zoom-in"] {
	transform: scale(0.92);
}

/* slide-right - a pure addition, nothing
   above needed to change. Mirrors slide-left's own translateX(40px) with
   the sign flipped (see that rule's own comment on this file's naming
   convention: named by the direction of MOTION during reveal, so the
   pre-state offset is the opposite sign).
   There is deliberately no `bounce` rule here: `bounce` is not in
   animation-props.php's whitelist, and a rule only an absent whitelist
   entry could ever match has no reason to exist - see that whitelist's own
   comment, and the CATALOG sub-heading at the end of this file. */
.kcb-animate.kcb-animate-init[data-kcb-animate-effect="slide-right"] {
	transform: translateX(-40px);
}

/* Settled/revealed state - added by the runtime once the trigger condition
   (scroll-into-view or page-load) fires. opacity:1/transform:none is also
   exactly what an unconfigured `.kcb-animate` div already looks like with
   no classes at all, so this class only ever moves the element TOWARD its
   natural resting appearance, never away from it. */
.kcb-animate.kcb-animate-in[data-kcb-animate-effect] {
	opacity: 1;
	transform: none;
}

/* Parallax's own continuous transform is applied directly as an inline
   style by the runtime (translate3d), not via a class - will-change is a
   real perf hint here (not premature optimization) because this property
   IS rewritten on effectively every scroll frame for the lifetime of the
   page, unlike the one-shot reveal transforms above. Gated on the same
   `not (pointer: coarse)` condition assets/js/animate-blocks.js uses to
   skip its entire parallax branch on a touch device - that branch never
   runs there, so a coarse-pointer element's transform is never rewritten
   at all, and the hint would otherwise ask the browser to hold a
   composited layer open for the rest of the page's life with nothing ever
   using it. `not (pointer: coarse)`, not `(pointer: fine)`: the JS gate is
   a negation (`!matches`), true for "fine", "none", and an unsupported
   media feature alike, so the CSS negation matches it exactly - `(pointer:
   fine)` alone would disagree with the JS on that last case. */
@media not (pointer: coarse) {
	.kcb-animate[data-kcb-animate-effect="parallax"] {
		will-change: transform;
	}
}

/*
 * Parallax containment for content-bg-image specifically (2026-08-22 fix -
 * "I only want the background image parallaxing within its contained
 * box"). animate-blocks.js's parallax translates the WHOLE `.kcb-animate`
 * wrapper - background, overlay, and heading/button text together, as one
 * rigid unit (there is no separate background-only layer to move
 * independently) - so at the animation's max +/-60px offset the wrapper
 * visibly slides past its own footprint, exposing page background on one
 * edge. `overflow: hidden` on the wrapper clips that slide to the block's
 * own box, matching every other full-bleed band on this site (nothing
 * outside a section's own edges should ever move). Scoped with `:has()`
 * to content-bg-image specifically, not every parallax use of the generic
 * Animate panel (e.g. a small photo or icon deliberately peeking past its
 * box during its drift) - `:has()` has been shipping in every evergreen
 * browser since 2023, no fallback needed.
 */
.kcb-animate[data-kcb-animate-effect="parallax"]:has(> .kcb-content-bg-image) {
	overflow: hidden;
}

/* Belt-and-suspenders with assets/js/animate-blocks.js's own early return:
   even if some other script ever added `.kcb-animate-init` while reduced
   motion is on, this forces the settled/visible state anyway - the CSS
   itself refuses to honor the hidden pre-state under this media query,
   matching the existing coverflow/timed/scattered `prefers-reduced-motion`
   backstops above in this same file. !important is deliberate here (not
   used lightly elsewhere in this file): this is the one place a real
   inline-vs-class fight is actually a live possibility (a future script
   toggling `.kcb-animate-init` directly), and accessibility correctness
   under this setting is non-negotiable per the feature brief, not a nice-
   to-have that a later, more-specific rule should be allowed to win over. */
@media (prefers-reduced-motion: reduce) {
	.kcb-animate.kcb-animate-init[data-kcb-animate-effect] {
		opacity: 1 !important;
		transform: none !important;
		transition: none !important;
	}

	/* Parallax (docs/specs/PARALLAX-VARIATIONS-2026-08-13.md):
	   assets/js/animate-
	   blocks.js already checks prefers-reduced-motion once, at script load,
	   before ever touching a parallax element's transform - but "once, at
	   load" means a user who changes that OS setting mid-session (no
	   reload) keeps whatever inline transform the script already applied,
	   frozen at its last scroll position, for the rest of that page view.
	   This query re-evaluates live, so it wins the instant the setting
	   changes - !important for the same reason as the rule above: an
	   inline style="transform:..." from the runtime would otherwise always
	   beat a plain class-free attribute-selector rule. Only `transform` is
	   forced here, not `opacity`/`transition` like the rule above -
	   parallax never sets either of those, so forcing them would be a
	   no-op at best and a lie about what this effect ever touches at
	   worst. */
	.kcb-animate[data-kcb-animate-effect="parallax"] {
		transform: none !important;
	}
}

/* ============================================================================
   ANIMATE - CATALOG (see docs/specs/ANIMATION-CATALOG-2026-08-13.md)
   ============================================================================
   Strictly appended after the section above rather than interleaved with
   it. The rules above (the shared .kcb-animate/-init/-ready/-in base rules,
   per-effect pre-state overrides, the reduced-motion backstop) were being
   rewritten concurrently by a parallel lane's own FOUC-bug fix
   (assets/js/animate-blocks.js's `.kcb-animate-ready` class, added ~2 rAFs
   after `.kcb-animate-init` and merged to main ahead of this branch - see
   that rule's own comment above). Every rule below is written directly
   against that already-merged three-class contract, not against the
   two-class shape this branch's own copy of the section above still shows
   (this branch was deliberately not rebased onto that merge; a separate
   lane resolves it). Practical consequence for every rule below: NONE of
   them declare their own `transition` - the shared
   `.kcb-animate-ready[data-kcb-animate-effect]` rule already carries
   opacity/transform/clip-path/filter in its transition-property list, so a
   per-effect rule that also declared `transition` here would be redundant
   at best and a silent second source of truth at worst. Every `-init` rule
   below sets ONLY the hidden/pre-state value(s); every `-in` rule below
   sets ONLY the revealed value(s). Getting this wrong is not a subtle
   timing glitch for any effect below that (like every one of them except
   Ken Burns) never touches opacity: it plays a fully undisguised
   clip-shut-then-open or blur-up-then-sharpen in front of an already fully
   opaque photo.

   bounce's two former rules (a pre-state override and its own
   overshoot-easing override, both immediately above this section) are
   DELETED outright, not just unreferenced - see this file's own comment
   where slide-right's rule now stands alone, and animation-props.php's
   whitelist comment, for the full removal reasoning. */

.kcb-animate.kcb-animate-init[data-kcb-animate-effect="slide-down"] {
	transform: translateY(-40px);
}

/* Wipe family - own opacity (always 1, never hidden via opacity the way
   fade/slide/zoom are) - reveals via clip-path alone. Values unchanged
   from the approved reference demo (docs/specs/animation-catalog-demo.html
   / ANIMATION-CATALOG-2026-08-13.md §9). wipe-left/right/up/down each pin
   one edge's inset at 0 and animate the OPPOSITE edge's own inset from
   100% down to 0%, so e.g. wipe-left's clip rectangle stays pinned to the
   left edge throughout while its right-side inset retreats - the visible
   region grows left-to-right, i.e. the reveal boundary itself sweeps
   rightward (worth noting for anyone expecting these names to track
   on-screen sweep direction the way slide-*'s names track motion
   direction above - they track which inset value animates instead;
   verified by inspecting the values, not renamed here since the demo's
   actual approved visual behavior is what matters, not which naming
   convention it happens to match). wipe-iris (not a direction - see
   animation-props.php's whitelist comment for why it's still a flat value
   rather than living in a direction sub-prop) is a circle mask expanding
   from a center point past the far corner (150% - a plain 100% radius
   still leaves a square viewport's own corners clipped; 150% comfortably
   clears them regardless of the element's own aspect ratio). */
.kcb-animate.kcb-animate-init[data-kcb-animate-effect="wipe-left"]  { opacity: 1; clip-path: inset(0 100% 0 0); }
.kcb-animate.kcb-animate-init[data-kcb-animate-effect="wipe-right"] { opacity: 1; clip-path: inset(0 0 0 100%); }
.kcb-animate.kcb-animate-init[data-kcb-animate-effect="wipe-up"]    { opacity: 1; clip-path: inset(100% 0 0 0); }
.kcb-animate.kcb-animate-init[data-kcb-animate-effect="wipe-down"]  { opacity: 1; clip-path: inset(0 0 100% 0); }
.kcb-animate.kcb-animate-init[data-kcb-animate-effect="wipe-iris"]  { opacity: 1; clip-path: circle(0% at 50% 50%); }
.kcb-animate.kcb-animate-in[data-kcb-animate-effect="wipe-left"],
.kcb-animate.kcb-animate-in[data-kcb-animate-effect="wipe-right"],
.kcb-animate.kcb-animate-in[data-kcb-animate-effect="wipe-up"],
.kcb-animate.kcb-animate-in[data-kcb-animate-effect="wipe-down"]   { clip-path: inset(0); }
.kcb-animate.kcb-animate-in[data-kcb-animate-effect="wipe-iris"]   { clip-path: circle(150% at 50% 50%); }

/* Focus pull - blur, own opacity always 1. will-change is scoped to the
   pre-reveal/actively-revealing window only, via :not(.kcb-animate-in) -
   since `-in` is added and never removed (same accumulate-only class
   pattern every effect here relies on - see the shared rules above), this
   hint drops itself the instant `-in` lands rather than staying active for
   the rest of the element's time on the page once its one-shot reveal is
   long over. This is a static mitigation only: blur() is one of the more
   expensive filter functions to keep compositor-hinted indefinitely, but
   whether 14px is cheap enough on real (especially lower-end/mobile GPU)
   hardware is UNVERIFIED from this Mac lane - no way to profile actual
   paint/composite cost exists here - flagged for the PC checklist. If it
   measures expensive there, the next lever is restricting the radius
   (14px was chosen for visual effect, not perf, so there's room to lower
   it) before reaching for a structural change (e.g. JS removing
   will-change on transitionend, which would need a change to
   assets/js/animate-blocks.js this lane doesn't own). */
.kcb-animate.kcb-animate-init[data-kcb-animate-effect="focus-pull"] { opacity: 1; filter: blur(14px); }
.kcb-animate.kcb-animate-init[data-kcb-animate-effect="focus-pull"]:not(.kcb-animate-in) { will-change: filter; }
.kcb-animate.kcb-animate-in[data-kcb-animate-effect="focus-pull"]   { filter: blur(0); }

/* Monochrome bloom - grayscale, own opacity always 1. No will-change:
   grayscale() is cheap relative to blur() (a per-pixel color matrix, no
   sampling radius), and this codebase reserves will-change for cases with
   an actual measured or clearly-reasoned cost, not by default on every
   filter-animating rule (matches parallax's own will-change comment
   above: "a real perf hint here... not premature optimization"). */
.kcb-animate.kcb-animate-init[data-kcb-animate-effect="monochrome-bloom"] { opacity: 1; filter: grayscale(100%); }
.kcb-animate.kcb-animate-in[data-kcb-animate-effect="monochrome-bloom"]   { filter: grayscale(0%); }

/* Ken Burns - continuous ambient pan/zoom, structurally unlike every
   effect above it (a one-shot reveal TRANSITION, over almost as soon as
   it's noticed). opacity always 1, written explicitly rather than left
   implicit (identical to unconfigured resting appearance - see this
   section's own top comment on `.kcb-animate[data-kcb-animate-
   effect="..."]` alone setting nothing) so a future reader doesn't mistake
   the absence of a hidden pre-state for an oversight. transform is driven
   by @keyframes, not by the shared transition list.

   That last fact is WHY this effect needs no special-casing anywhere in
   the -init/-ready/-in machinery or in assets/js/animate-blocks.js despite
   being structurally different from everything above it - independently
   reasoned through here, not just adopted from this feature's own
   coordination thread, which had explicitly left the question open rather
   than asserting an answer. A CSS Animation and a CSS Transition
   contesting the SAME property on the same element don't fight for it the
   way two transitions would: per the CSS Transitions spec, a transition
   never starts on a property a running CSS Animation is actively driving,
   full stop. Concretely: the `-in` rule below never sets `transform` as a
   plain value at all, only `animation` - so there is no value change for
   `-ready`'s own transform-transition list entry to ever act on for this
   effect in the first place. It sits there, unused, harmlessly. Because
   `-ready` is generic/shared rather than effect-specific, this effect gets
   to sit out that list with no opt-out rule needed anywhere. The only
   load-bearing consequence of Ken Burns still going through the ordinary
   init-then-ready-then-in lifecycle (rather than keying the animation off
   some other signal) is timing: `-in` only lands once the real reveal
   trigger (scroll-into-view or load, staggered/image-gated exactly like
   every other effect - see animate-blocks.js) actually fires, so the
   pan/zoom begins when the photo is actually seen, not the instant the
   page starts parsing.

   Respects the author's own Easing field (var(--kcb-anim-easing)) rather
   than hardcoding one - a correction from this effect's own original spec
   draft (docs/specs/ANIMATION-CATALOG-2026-08-13.md §9), which hardcoded
   ease-out with no principled reason to override the author's choice the
   way bounce's overshoot curve genuinely needed to (no single easing curve
   is definitionally "the" Ken Burns look, the way overshoot was
   definitionally "the" bounce look). After bounce's removal above, every
   remaining effect in this file honors the author's Easing field with
   zero exceptions. Also adds var(--kcb-anim-delay) to the animation
   shorthand, which that same spec draft omitted - every other effect
   already respects the author's Delay field; Ken Burns has no principled
   reason to be the one effect that doesn't.

   Duration's real enforced range for this effect is 6000-20000ms, not the
   universal 100-3000ms every other effect uses - see animation-props.php's
   own per-effect duration-clamp comment, and this catalog's own §11
   addendum for why the demo's 9000ms example didn't already prove that
   range was enforced. */
@keyframes kcb-kenburns {
	from { transform: scale(1); }
	to   { transform: scale(1.12); }
}
.kcb-animate.kcb-animate-init[data-kcb-animate-effect="ken-burns"] { opacity: 1; transform: none; }
.kcb-animate.kcb-animate-in[data-kcb-animate-effect="ken-burns"]   { animation: kcb-kenburns var(--kcb-anim-duration) var(--kcb-anim-easing) var(--kcb-anim-delay) forwards; }

/* Reduced-motion coverage for this section's three new mechanisms
   (clip-path/filter/animation) - additive to, not a replacement for, the
   existing backstop above (which already forces opacity/transform/
   transition for every effect value, including all of these, since its
   own selector carries no effect-value qualifier - it fires for ANY
   `data-kcb-animate-effect`). That existing rule predates clip-path/
   filter/animation existing anywhere in this file, so it has no way to
   neutralize them; scoped in its own rule here instead of editing that
   shared rule in place, so this addition can't collide with any other
   lane's own concurrent edit to it - same append-only reasoning as this
   whole section. Equal selector specificity to the existing backstop (two
   classes + one attribute selector, with or without a value match, weigh
   the same) but zero overlapping properties between the two rules, so
   there's nothing for source order to even need to arbitrate - each rule
   exclusively owns the properties it declares. Keyed on `-init` (never
   `-in`) for the same reason the existing rule is: `-init` is added first
   and never removed, so it remains matched - and, via !important, winning
   - for the element's entire remaining lifecycle even after `-ready`/`-in`
   are later added on top of it. */
@media (prefers-reduced-motion: reduce) {
	.kcb-animate.kcb-animate-init[data-kcb-animate-effect="wipe-left"],
	.kcb-animate.kcb-animate-init[data-kcb-animate-effect="wipe-right"],
	.kcb-animate.kcb-animate-init[data-kcb-animate-effect="wipe-up"],
	.kcb-animate.kcb-animate-init[data-kcb-animate-effect="wipe-down"],
	.kcb-animate.kcb-animate-init[data-kcb-animate-effect="wipe-iris"],
	.kcb-animate.kcb-animate-init[data-kcb-animate-effect="focus-pull"],
	.kcb-animate.kcb-animate-init[data-kcb-animate-effect="monochrome-bloom"],
	.kcb-animate.kcb-animate-init[data-kcb-animate-effect="ken-burns"] {
		clip-path: none !important;
		filter: none !important;
		animation: none !important;
	}
}


/*
 * Block-level horizontal alignment (`block_align` style prop, emitted by
 * kedynsierra_builder_wrap_style_props() as data-align on .kcb-style-wrap).
 *
 * The wrapper's own inline text-align already handles inline and inline-block
 * children - images, buttons, captions. These rules cover the other case: a
 * block-level child with a width of its own, which text-align cannot move.
 *
 * Applied to every direct child rather than to a detected subset, because auto
 * margins on a child that already fills the width are a no-op - there is
 * nothing to detect. Scoped to [data-align] so a block with no alignment set
 * matches nothing and renders exactly as it did before this existed.
 */
/*
 * Clearfix, unconditional on every `.kcb-style-wrap` (2026-08-22 fix - live
 * bug: homepage portfolio-grid tiles unclickable, and the expedition teaser
 * block rendered ~1259px too tall with a huge dead zone above its visible
 * content, overlapping the grid below it). Root cause: `.kcb-style-wrap` is
 * a bare `display:block` div (style-props.php's kedynsierra_builder_wrap_
 * style_props()) with no assumption either way about its child's layout
 * mode - but templates/portfolio-grid.php's real rendered output
 * (`.ppb_portfolio.one`) is float:left, a legacy theme column class
 * (screen.css's `.one`/`.one_half`/etc grid system, meant to be used inside
 * a clearfixed `.row`). A floated child does not contribute to an
 * unclearfixed parent's height, so the portfolio-grid block's own
 * `.kcb-style-wrap` collapsed to 0 height while its floated content kept
 * painting at its full visual size - any later sibling then started
 * painting from that same collapsed top offset, overlapping the still-
 * visible float. Harmless for every other block type (non-floated content
 * has nothing to clear, so this is a no-op there) - same clearfix idiom
 * already used elsewhere in this codebase for the identical legacy-float
 * class of bug (e.g. css/portfolio-grid.css's own `.element::after` fix).
 */
.kcb-style-wrap::after {
	content: '';
	display: table;
	clear: both;
}

/*
 * A shadow set on a Portfolio Grid block paints per TILE, not around the
 * block's one giant full-width box (which just draws stray lines across
 * the page). The wrap emits its shadow as --kcb-shadow + box-shadow:var()
 * inline (style-props.php), so the custom property inherits down to the
 * tiles; !important is required specifically to beat that inline
 * box-shadow declaration - specificity alone can never win against an
 * inline style.
 */
.kcb-style-wrap:has(> .ppb_portfolio) {
	box-shadow: none !important;
}

.kcb-style-wrap:has(> .ppb_portfolio) .pm-uniform-grid__frame {
	box-shadow: var(--kcb-shadow, none);
}

/*
 * Same forwarding for slider blocks (Gallery Slider / Portfolio Slider /
 * bare slider_wrapper): the wrap spans the full content column (~1245px)
 * while the flexslider inside is narrower (1100px) and height-synced to
 * the active photo, so a wrap-level shadow draws a card with white
 * margins around the photo (Kedyn's "galleries still carrying white
 * letterboxes", 2026-08-22). Paint the shadow on the box that actually
 * hugs the photo instead: .flex-viewport in slide mode, .flexslider
 * itself in fade mode (this vendored build has no viewport element in
 * fade mode - same p ? r : r.viewport split smoothHeight uses).
 */
.kcb-style-wrap:has(> .slider_wrapper) {
	box-shadow: none !important;
}

.kcb-style-wrap:has(> .slider_wrapper) .flexslider .flex-viewport,
.kcb-style-wrap:has(> .slider_wrapper) .flexslider:not(:has(.flex-viewport)) {
	box-shadow: var(--kcb-shadow, none);
}

.kcb-style-wrap[data-align="center"] > * {
	margin-left: auto;
	margin-right: auto;
}

.kcb-style-wrap[data-align="right"] > * {
	margin-left: auto;
	margin-right: 0;
}

.kcb-style-wrap[data-align="left"] > * {
	margin-left: 0;
	margin-right: auto;
}

/*
 * anchor-nav / trip-map (includes/blocks/blocks-navigation.php) - a long
 * story page's "jump to a section" pills, and the interactive route map
 * whose pin popups link to those same targets.
 */

/*
 * Scoped to .kcb-js (documentElement class set by content-filter.php's own
 * animate-gate script, wp_head priority 1) rather than a bare `html` rule -
 * this project's one other sitewide `html`-level rule (reduced-motion
 * animate gate, same file) uses the identical scoping convention so a
 * smooth-scroll preference never fights that class being toggled off.
 */
@media (prefers-reduced-motion: no-preference) {
	/*
	 * Plain `html`, deliberately (2026-08-24 audit). This was
	 * `.kcb-js html`, which could never match at all: content-filter.php puts
	 * that class on document.documentElement - on <html> ITSELF - so the
	 * descendant form asked for an <html> nested inside a .kcb-js element.
	 *
	 * Correcting it to `html.kcb-js` was still not enough, which is why the
	 * gate is gone entirely: that class is ADDED at wp_head and then REMOVED
	 * again on window load unless window.kcbAnimateReady is set, and
	 * animate-blocks.js only loads on pages whose tree contains an animated
	 * node. Measured live on /ten-years-later/ - the page whose twelve-city
	 * jump nav is the whole reason this rule exists - the class was gone by
	 * load, so smooth scrolling would still never have applied.
	 *
	 * Tying scroll behaviour to the animation runtime was the mistake. The
	 * reduced-motion query above is the correct and sufficient gate: it is
	 * exactly the preference that should decide whether a jump link glides or
	 * snaps, and it needs no JavaScript to be honoured.
	 */
	html {
		scroll-behavior: smooth;
	}
}

.kcb-anchor-nav {
	display: flex;
	flex-wrap: wrap;
	gap: 14px;
	justify-content: center;
	/* Breathing room above/below: this block commonly sits directly beneath
	   a trip-map and directly above whatever content follows, with no
	   Spacer block of its own in between (unlike most of this page's other
	   sections, which have an explicit Spacer node). */
	padding: 28px 20px;
}

.kcb-anchor-nav__link {
	display: inline-flex;
	align-items: center;
	/*
	 * border-box matters here: this stylesheet does not inherit a global
	 * border-box reset, so a bare min-height would sit on the CONTENT box and
	 * add the 20px padding and 2px border on top - measured live at 66px for a
	 * 44px target, which reads as bloated across a ten-link bar.
	 */
	box-sizing: border-box;
	min-height: 44px;
	padding: 10px 20px;
	border: 1px solid var(--color-accent, #92573b);
	border-radius: 999px;
	color: var(--color-accent, #92573b);
	font-family: Oswald, sans-serif;
	font-size: 13px;
	letter-spacing: 0.04em;
	text-transform: uppercase;
	text-decoration: none;
	transition: background-color 0.2s ease, color 0.2s ease;
}

.kcb-anchor-nav__link:hover,
.kcb-anchor-nav__link:focus-visible {
	background-color: var(--color-accent, #92573b);
	color: #fff;
}

/* Sticky jump-bar built client-side by assets/js/kcb-anchor-bar.js from the
   inline nav above (see that file's docblock) - fixed to the viewport
   BOTTOM (the theme's own header owns the top on mobile), hidden until the
   inline nav scrolls up out of view. Dark panel palette matches the
   theme's mobile menu (#161310 family) so the bar reads as site chrome,
   not page content. */
.kcb-anchor-bar {
	position: fixed;
	left: 0;
	right: 0;
	bottom: 0;
	z-index: 90;
	background: rgba(22, 19, 16, 0.94);
	backdrop-filter: blur(8px);
	-webkit-backdrop-filter: blur(8px);
	padding-bottom: env(safe-area-inset-bottom, 0);
	transform: translateY(110%);
	/* visibility, not transform alone (2026-08-24 audit): a translated element
	   is still rendered and still FOCUSABLE, so all twelve cloned city links
	   sat in the tab order the entire time the bar was parked off-screen -
	   the same defect already fixed on the mobile menu panel. Delayed by the
	   slide duration so the bar stays visible for its whole travel outward. */
	visibility: hidden;
	transition: transform 0.3s ease, visibility 0s linear 0.3s;
}

.kcb-anchor-bar--visible {
	transform: translateY(0);
	visibility: visible;
	transition: transform 0.3s ease, visibility 0s linear 0s;
}

/* Reserve the bar's own height at the end of the document. The body class is
   added by kcb-anchor-bar.js whenever a bar exists; before this it was set
   and then read by nothing, so the fixed bar simply covered the last ~48px of
   the page - on the expedition page, the end of the final gallery. */
body.kcb-has-anchor-bar {
	padding-bottom: calc(48px + env(safe-area-inset-bottom, 0px));
}

@media (prefers-reduced-motion: reduce) {
	.kcb-anchor-bar,
	.kcb-anchor-bar--visible {
		transition: none;
	}
}

.kcb-anchor-bar__track {
	display: flex;
	gap: 2px;
	overflow-x: auto;
	-webkit-overflow-scrolling: touch;
	scrollbar-width: none;
	padding: 0 10px;
	/* Track scrolls, page never does - belt to the theme's own
	   overflow-x hygiene. */
	overscroll-behavior-x: contain;
	scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
	.kcb-anchor-bar__track {
		scroll-behavior: auto;
	}
}

.kcb-anchor-bar__track::-webkit-scrollbar {
	display: none;
}

.kcb-anchor-bar__link {
	flex: 0 0 auto;
	display: inline-flex;
	align-items: center;
	/* 15px vertical = 44px+ total tap height at this font size. */
	padding: 15px 12px;
	color: #e9e2d6;
	font-family: Oswald, sans-serif;
	font-size: 11px;
	letter-spacing: 0.08em;
	text-transform: uppercase;
	text-decoration: none;
	transition: color 0.2s ease;
}

.kcb-anchor-bar__link:hover,
.kcb-anchor-bar__link:focus-visible {
	color: #fff;
}

/* Current-section highlight, set by kcb-anchor-bar.js's scroll-spy - the
   lighter sienna the mobile menu uses on this same dark ground, not the
   base accent (#92573b), which is too dark to read here. */
.kcb-anchor-bar__link--current {
	color: #c48f6a;
}

.kcb-trip-map {
	width: 100%;
	border-radius: 4px;
	/* Leaflet paints its own background while tiles load - this fallback
	   only ever shows if the vendored library fails to load entirely
	   (network error, ad-blocker, etc), so the empty div reads as a quiet
	   placeholder rather than a stark white gap. */
	background: #edebe7;
}

/* Leaflet's own popup chrome, restyled to the site's serif/sienna language
   rather than its default blue-link Helvetica look. */
.kcb-trip-map .leaflet-popup-content-wrapper {
	border-radius: 4px;
	font-family: Lato, sans-serif;
}

.kcb-trip-map .leaflet-popup-content a {
	color: var(--color-accent, #92573b);
}

/* ==========================================================================
   First-party gallery slider engine (assets/js/kcb-slider.js - FlexSlider
   retirement, Field Plan Phase 5 item 4; stage 2 made it the ONLY engine).
   Every rule is gated on `[data-kcb-slider]`, emitted by all three markup
   producers: the gallery-slider block, the portfolio-slider block
   (templates/portfolio-slider.php), and kedynsierra-ui-ux's classic
   [tg_gallery_slider] shortcode - hence the shared `.slider_wrapper` base
   class in each selector rather than one block's own class. That extra
   class also deliberately out-specifies js/flexslider/flexslider.css's
   sitewide `.flexslider .slides > li { display: none }` (still enqueued
   globally by the theme purely as legacy box styling), which would
   otherwise blank every slide now that FlexSlider's init never runs to
   lift it.
   ========================================================================== */
.slider_wrapper[data-kcb-slider] .flexslider {
	position: relative;
	overflow: hidden;
	/* The engine sets an explicit pixel height measured from the active
	   slide (on slide change, image load, and resize); this transition is
	   what used to be FlexSlider's smoothHeight animation. */
	transition: height 0.4s ease;
}
.slider_wrapper[data-kcb-slider] .flexslider .slides {
	position: relative;
	margin: 0;
	padding: 0;
	list-style: none;
}
/*
 * Cross-fade, corrected 2026-08-28. Kedyn: the gallery transition looked
 * "a little broken/not finished" - two frames visible at once, a double
 * exposure.
 *
 * The slides are absolutely stacked, and BOTH used to animate opacity over
 * the same 0.6s: the outgoing fading 1 -> 0 while the incoming faded
 * 0 -> 1. Halfway through, each sits near 0.5, so the top frame is
 * half-transparent and you see straight through it to the one underneath.
 * That is not a dissolve, it is a blend, and on photographs it reads as a
 * fault.
 *
 * A dissolve wants exactly one thing moving: the incoming slide fades in
 * ON TOP of an outgoing one that stays fully opaque, then the outgoing
 * snaps to hidden once it is completely covered and nobody can see it go.
 *
 * That is what the two transitions below encode:
 *   active   - opacity 0.6s ease, and z-index 2, so it fades in on top
 *   inactive - opacity 0s DELAYED by 0.6s, so it holds full opacity
 *              underneath for the whole fade and then cuts out
 *
 * Keep the two durations equal. If the delay is ever shorter than the
 * fade, the outgoing vanishes early and the blend comes back.
 */
.slider_wrapper[data-kcb-slider] .flexslider .slides > li {
	display: block;
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	margin: 0;
	opacity: 0;
	transition: opacity 0s linear 0.6s;
	z-index: 1;
	/* A hidden slide's lightbox link must not be clickable through the
	   active one. */
	pointer-events: none;
}
.slider_wrapper[data-kcb-slider] .flexslider .slides > li.kcb-slide-active {
	opacity: 1;
	transition: opacity 0.6s ease;
	z-index: 2;
	pointer-events: auto;
}
/*
 * A30 (2026-08-26 audit): no-JS / pre-init fallback.
 *
 * The rule above hides EVERY slide with opacity:0, and only kcb-slider.js
 * adds .kcb-slide-active - so with the engine absent (JavaScript off, or the
 * script dropped from a Jetpack Boost bundle) every canvas gallery renders as
 * an empty box. /ten-years-later/ carries twelve of them.
 *
 * flexslider.css:70's own `.no-js .slides > li:first-child { display:block }`
 * escape hatch cannot help, for two independent reasons: it targets `display`
 * when the hiding is done with `opacity`, and NOTHING anywhere in the theme or
 * either first-party plugin ever writes a `no-js` class (verified by grep -
 * the only match on the whole site is an unrelated `hide-if-no-js` in
 * functions/js-wp-editor.js). Keying a fix on that class would be a no-op.
 *
 * Keyed instead on the engine's OWN init marker: kcb-slider.js:71 sets
 * `wrapper.dataset.kcbSliderInit = '1'` immediately after its re-entry guard
 * at :68, before any style or layout read - so this rule stops matching
 * before the browser can resolve a computed opacity. No flash, and no
 * spurious 0.6s fade on a normal JS-enabled load.
 *
 * `transform: none` is load-bearing, not tidiness: the coverflow and
 * scattered layouts scale non-active slides down (blocks.css :1506-1511 and
 * :1652-1657, specificity 0,3,1). This selector is 0,6,1, so it wins and the
 * fallback slide renders unscaled.
 *
 * pointer-events is deliberately NOT restored. The slide's lightbox anchor
 * points at the full-size file and the PhotoSwipe binding has not run, so a
 * click without JS would hand the visitor a multi-megabyte original instead
 * of opening a viewer. Better to show the photograph and leave it inert.
 */
.slider_wrapper[data-kcb-slider]:not([data-kcb-slider-init]) .flexslider .slides > li:first-child {
	opacity: 1;
	z-index: 2;
	transform: none;
}
.slider_wrapper[data-kcb-slider] .flexslider .slides img {
	display: block;
	width: 100%;
	height: auto;
}

/*
 * Coverflow/scattered under the engine: those layouts' own rules (the
 * coverflow/scattered sections earlier in this file) declare
 * `transition: transform ...` on the <li> at the same specificity tier as
 * the engine's base `transition: opacity 0.6s ease` above - and since this
 * section sits later in the file, the engine's declaration would win the
 * tie and silently kill the card-movement easing. Declaring both
 * transitions together here (higher specificity via the data-layout gate)
 * keeps the crossfade AND the transform easing.
 */
.slider_wrapper[data-kcb-slider][data-layout="coverflow"] .flexslider .slides > li,
.slider_wrapper[data-kcb-slider][data-layout="scattered"] .flexslider .slides > li {
	transition: opacity 0.6s ease, transform 0.35s ease;
}

/*
 * Timed enter/exit choreography under the engine (`data-transition=
 * "timed"`). Same class names and `--kcb-enter-*`/`--kcb-exit-*` custom
 * properties as the retired FlexSlider implementation (kcb-slider.js sets
 * the properties from wrapper data- attributes and runs the class dance:
 * exiting on the outgoing slide, enter-from for one painted frame then
 * entering on the incoming one), with end states adapted to this engine's
 * absolute-stacked geometry: the exiting slide must fade fully OUT
 * (FlexSlider's slide mode moved it away horizontally instead, so 0.4 was
 * enough there), and the entering slide rises from fully transparent.
 * The base rule kills the engine's own 0.6s crossfade so the per-class
 * transitions below are the only animation driver in this mode.
 */
.slider_wrapper[data-kcb-slider][data-transition="timed"] .flexslider .slides > li {
	transition: none;
}
.slider_wrapper[data-kcb-slider][data-transition="timed"] .flexslider .slides > li.kcb-slide-exiting {
	transition: opacity var(--kcb-exit-duration, 400ms) var(--kcb-exit-easing, ease-in) var(--kcb-exit-offset, 0ms),
		transform var(--kcb-exit-duration, 400ms) var(--kcb-exit-easing, ease-in) var(--kcb-exit-offset, 0ms);
	opacity: 0;
	transform: scale(0.96);
	z-index: 2;
}
.slider_wrapper[data-kcb-slider][data-transition="timed"] .flexslider .slides > li.kcb-slide-enter-from {
	transition: none;
	opacity: 0;
	transform: scale(0.98) translateY(10px);
}
.slider_wrapper[data-kcb-slider][data-transition="timed"] .flexslider .slides > li.kcb-slide-entering {
	transition: opacity var(--kcb-enter-duration, 500ms) var(--kcb-enter-easing, ease-out) var(--kcb-enter-offset, 0ms),
		transform var(--kcb-enter-duration, 500ms) var(--kcb-enter-easing, ease-out) var(--kcb-enter-offset, 0ms);
	opacity: 1;
	transform: scale(1) translateY(0);
	z-index: 3;
}

/*
 * The .kcb-slide-active selector is listed too, and has to be: the
 * crossfade fix moved the fade declaration OFF the base `> li` rule and
 * onto the active one, which is a specificity tier higher. A blanket
 * `> li { transition: none }` no longer reaches it, so without this line
 * a reduced-motion visitor would still get the 0.6s fade.
 */
@media (prefers-reduced-motion: reduce) {
	.slider_wrapper[data-kcb-slider] .flexslider,
	.slider_wrapper[data-kcb-slider] .flexslider .slides > li,
	.slider_wrapper[data-kcb-slider] .flexslider .slides > li.kcb-slide-active,
	.slider_wrapper[data-kcb-slider][data-layout="coverflow"] .flexslider .slides > li,
	.slider_wrapper[data-kcb-slider][data-layout="scattered"] .flexslider .slides > li {
		transition: none;
	}
}


/* ==========================================================================
 * Full-bleed bands and colour-bearing wraps (2026-08-27)
 * ========================================================================== */

/*
 * The scrollbar-gutter overhang .kcb-row--full-bleed's own comment predicted
 * ("if it bites, the fix belongs on an ancestor (overflow-x) rather than
 * here"), now measured in a real browser: every 50vw bleed widens an element
 * to 100vw, but 100vw INCLUDES the classic scrollbar gutter while the
 * containing block is only the client width. The page scrolled sideways by
 * 8px at a 2166px viewport and 12px at 584px.
 *
 * `clip`, not `hidden`: overflow-x:hidden would make the element a scroll
 * container, which silently breaks position:sticky for every descendant - and
 * this page ships a sticky anchor bar. `clip` clips without establishing one.
 *
 * On <main> and NOT on <html>, which was the first attempt and is a no-op:
 * overflow set on the root element PROPAGATES to the viewport, leaving the
 * root itself computing as visible, so it never clips its own children.
 * Confirmed live - html computed `overflow-x: clip` while the page still
 * scrolled 8px; moving the same declaration to <main> took it to 0. <main>
 * is exactly the client width, so the band still reaches both true viewport
 * edges and only the scrollbar-width overhang is trimmed.
 */
main {
	overflow-x: clip;
}

/*
 * Restores colour inheritance inside a wrap whose author set a font colour.
 * See kedynsierra_builder_wrap_style_props() (style-props.php) for why the
 * class exists and why an ungated version of this rule would repaint headings
 * across the whole site. `inherit` rather than a literal so one rule serves a
 * light font colour on a dark band and a dark one on a light band equally.
 */
.kcb-style-wrap--has-font-color h1,
.kcb-style-wrap--has-font-color h2,
.kcb-style-wrap--has-font-color h3,
.kcb-style-wrap--has-font-color h4,
.kcb-style-wrap--has-font-color h5,
.kcb-style-wrap--has-font-color h6,
.kcb-style-wrap--has-font-color blockquote,
.kcb-style-wrap--has-font-color cite,
.kcb-style-wrap--has-font-color figcaption,
.kcb-style-wrap--has-font-color pre,
.kcb-style-wrap--has-font-color code,
.kcb-style-wrap--has-font-color tt,
.kcb-style-wrap--has-font-color th,
.kcb-style-wrap--has-font-color td {
	color: inherit;
}

/*
 * An inner column for a bled band.
 *
 * Bleeding the wrap makes the band's CONTENTS viewport-wide too, which is
 * almost never what a photo band wants: measured on the aurora band, the
 * photographs sat at 1200px while the text ran 2118px, so the caption was the
 * widest element on the page and each timestamp - right-aligned - landed
 * roughly 900px from the frame it labels.
 *
 * Constraining the row to the theme's own content width (grid.css: 1425px
 * container less 2x90px padding = 1245px) means only the COLOUR bleeds, while
 * the contents stay on the same axis as the un-bled sections above and below.
 * The photographs then nearly fill the column and become the widest thing in
 * the band, which is the correct reading of a full-bleed photo essay.
 *
 * Two classes (0,2,0) so this reliably beats .kcb-row--full-bleed's own
 * negative margins (0,1,0) on specificity rather than on source order.
 */
.kcb-style-wrap--full-bleed > .kcb-row {
	max-width: var(--kcb-bleed-inner, 1245px);
	margin-left: auto;
	margin-right: auto;
}

/*
 * Reading measure for prose inside that column. 1245px is right for
 * photographs and far too wide for text, so body copy takes the site's own
 * --measure-reading token (reset.css: 640px, documented there as close to the
 * Work Sans ~75-character ideal).
 *
 * Left-aligned deliberately - no auto margins - so the prose column shares its
 * left edge with the band's left-aligned photographs instead of floating in
 * the middle of them.
 *
 * Right-aligned wraps are excluded: those are the seven aurora timestamps and
 * the exposure line, whose whole job is to sit under the right edge of the
 * frame above. Capping them at 640px would drag them back to the left.
 */
.kcb-style-wrap--full-bleed .kcb-style-wrap:not([data-align="right"]) > .kcb-heading,
.kcb-style-wrap--full-bleed .kcb-style-wrap:not([data-align="right"]) > .kcb-paragraph,
.kcb-style-wrap--full-bleed .kcb-pull-quote {
	max-width: var(--measure-reading, 640px);
}


/*
 * Jump links landed underneath the header.
 *
 * This theme's header is `position: fixed` and 146px tall (measured live, and
 * it does NOT shrink on scroll), while every anchor target computed
 * `scroll-margin-top: 0`. So each of this page's ten jump links scrolled its
 * heading to y=0 - directly behind the header - and the reader arrived at a
 * section whose title they could not see. On a page whose entire navigation is
 * anchor jumps that is the single most-used interaction on it.
 *
 * Applied to the elements that actually carry an id: anchor_id is emitted on
 * the block element itself, not on the style wrapper. The mobile value is
 * smaller because the header collapses to the compact bar there; both are
 * generous by a few pixels on purpose, since landing slightly low reads as
 * deliberate and landing high hides the heading.
 */
.kcb-heading[id],
.kcb-paragraph[id],
.kcb-row[id],
[data-kcb-type][id] {
	scroll-margin-top: 160px;
}

@media (max-width: 767px) {
	.kcb-heading[id],
	.kcb-paragraph[id],
	.kcb-row[id],
	[data-kcb-type][id] {
		scroll-margin-top: 110px;
	}
}


/*
 * Heading rhythm. reset.css zeroes margin on h1-h6 and NOTHING restores it, so
 * every heading block on this site rendered with 16px above (the preceding
 * paragraph's own margin) and 0 below - a section title glued to the text it
 * introduces. That is why this plugin has no .kcb-heading rule at all, why the
 * Alaska tree needed six manual Spacer nodes, and why /contact/ had to patch
 * margin-bottom by hand.
 *
 * em, so the gap tracks the heading's own size: an h2 opens a section wider
 * than an h4 opens a subsection. Margins collapse against the preceding
 * paragraph's 1em, so the effective gap is max(1.6em, 1em), never the sum.
 *
 * Scoped to .kcb-heading - the heading BLOCK - so accordion headers
 * (.kcb-accordion__header, also h3) keep their widget layout.
 *
 * The reset is deliberately `.kcb-style-wrap:first-child > .kcb-heading` and
 * NOT `.kcb-heading:first-child`: every heading is wrapped, so it is always
 * its wrapper's first child, and the simpler selector would zero the margin on
 * every heading on the site and silently undo this whole rule.
 */
.kcb-heading {
	margin: 1.6em 0 0.5em;
}

.kcb-style-wrap:first-child > .kcb-heading {
	margin-top: 0;
}


/*
 * Reading measure for ALL prose, not just prose inside a bled band.
 *
 * Measured live on the Alaska page: 37 body paragraphs at 1229px and 16px,
 * which is roughly 190 characters per line against a comfortable 65-75. The
 * band already had a measure; the other 90% of a 3,700-word essay did not.
 * The site already owns the number - reset.css --measure-reading: 640px,
 * documented there as close to the Work Sans ~75-character ideal - and
 * content-bg-image and the bled band both already consume it.
 *
 * max-width only, no margin change: a constrained block simply starts at its
 * container's left edge, so prose shares a left edge with left-aligned
 * photographs and the images stay the widest thing on the page. Any page that
 * deliberately centres its prose (the homepage sets margin:auto on its own
 * paragraphs) keeps doing so, because this touches a different property.
 *
 * :not([data-align="right"]) is load-bearing, not tidiness. The seven aurora
 * timestamps and the exposure line are right-aligned so they sit under the
 * right edge of the frame they label; capping those at 640px would drag every
 * one of them back to the middle of the band, away from its photograph.
 */
.kcb-style-wrap:not([data-align="right"]) > .kcb-paragraph,
.kcb-style-wrap:not([data-align="right"]) > .kcb-pull-quote,
.kcb-style-wrap:not([data-align="right"]) > .kcb-heading {
	max-width: var(--measure-reading, 640px);
	/*
	 * CENTRED, not flush left. The first version of this rule set max-width
	 * only, which left every paragraph hard against the left edge of a 1245px
	 * column with ~600px of dead space down the right - Kedyn's report was
	 * simply "everything is aligned left", and he was right. Photographs run
	 * the full column width, so a centred text column is what makes the page
	 * read as a photo essay rather than as a narrow strip of text with a hole
	 * beside it.
	 *
	 * Headings are included for the same reason: a 1245px full-width heading
	 * above a 640px centred paragraph is a worse mismatch than either choice
	 * on its own. Heading top/bottom margins come from the .kcb-heading rhythm
	 * rule above and survive - this only overrides the inline axis.
	 */
	margin-left: auto;
	margin-right: auto;
}

/*
 * The sticky jump bar floated above the open mobile menu and stayed tappable
 * through it - the menu is an off-canvas panel with no dimmer, and the bar is
 * position:fixed at z-index 90. Reuses the bar's own hidden state rather than
 * inventing a second one; body.js_nav is the theme's open-menu class
 * (screen.css). visibility and pointer-events both, because a translated
 * element is still rendered and still focusable - the same reasoning the
 * bar's own base rule already documents.
 */
body.js_nav .kcb-anchor-bar {
	transform: translateY(110%);
	visibility: hidden;
	pointer-events: none;
}


/*
 * Photographs fill the bled band's column.
 *
 * The seven aurora frames render at their 1200px `large` width inside a
 * 1245px column, and the timestamps that label them are right-aligned to the
 * COLUMN - so every caption sat about 45px past the right edge of its own
 * photograph (measured: photo right 1282, caption right 1327). Filling the
 * column puts the two edges on the same line, which is the entire point of
 * right-aligning a caption.
 *
 * A 1200px source stretched to 1245 is a 3.75% upscale - not resolvable on
 * screen - and the alternative (capping captions to the photo width) cannot
 * be expressed in CSS, because each photo's width depends on its own aspect
 * ratio. Scoped to the bled band so ordinary in-column images, which are not
 * captioned this way, keep their natural size.
 */
.kcb-style-wrap--full-bleed .kcb-image {
	width: 100%;
}


/*
 * Gallery (Masonry) rendered COMPLETELY INVISIBLE inside the builder.
 *
 * The block reuses the theme's gallery markup, and every cell it emits
 * carries `gallery_type`. screen.css parks that class at `opacity: 0` on
 * purpose: the theme fades the cells in from an inline script emitted by
 * templates/script-gallery-grid.php. That template only runs on the theme's
 * own gallery/portfolio templates - it is never part of a builder-rendered
 * page - so on any page built from the tree, the reveal never fires and all
 * 25 photographs sit there at zero opacity with correct geometry. Found by
 * measuring a blank-looking gallery whose images reported complete=true,
 * naturalWidth=1066 and opacity:1 on the <img> itself; the zero was two
 * levels up on div.one_third.gallery3.
 *
 * Scoped to .kcb-style-wrap, the builder's own wrapper, so the theme's
 * templates keep their staggered fade-in and only builder content - which
 * had no script to wait for - is forced visible. Two classes (0,2,0) beat
 * the theme's single-class rule.
 */
.kcb-style-wrap .gallery_type,
.kcb-style-wrap .portfolio_type {
	opacity: 1;
}

/*
 * ...except once gallery-reveal.js has ARMED the grid (2026-08-28).
 *
 * The rule above says builder content has "no script to wait for". That
 * stopped being true today: content-filter.php now enqueues the theme's
 * gallery-reveal.js for masonry trees on any page type, so a builder page
 * has exactly the staggered fade-in the theme's own templates get.
 *
 * But the two rules collide. Both .kcb-style-wrap .gallery_type and
 * portfolio-grid.css's .pm-reveal-armed .pm-uniform-grid__frame are 0,2,0,
 * and this file loads later, so opacity:1 won the tie and pinned every
 * tile opaque. The script ran, armed the grid and staggered the delays -
 * all measured live - and not one tile could fade, because its starting
 * opacity had already been overridden. The animation was there and simply
 * could not be seen.
 *
 * Three classes (0,3,0) settle it in the armed direction. The fail-safe
 * above is untouched and still does its job: .pm-reveal-armed is added
 * ONLY by JavaScript, so if the script is missing, blocked or throws, no
 * grid is ever armed, neither selector below matches, and every tile stays
 * at the forced opacity:1 - which is the whole reason that rule exists.
 */
.kcb-style-wrap .pm-reveal-armed .pm-uniform-grid__frame {
	opacity: 0;
}
.kcb-style-wrap .pm-reveal-armed .pm-uniform-grid__frame.is-revealed {
	opacity: 1;
}
/*
 * Belt-and-braces: gallery-reveal.js returns before arming anything when
 * prefers-reduced-motion is set, so in principle these selectors can never
 * match for such a visitor. Declared anyway, because "can never happen"
 * here rests on a guard clause in a different file - and the failure mode
 * if it ever moved is invisible photographs.
 */
@media (prefers-reduced-motion: reduce) {
	.kcb-style-wrap .pm-reveal-armed .pm-uniform-grid__frame {
		opacity: 1;
	}
}


/*
 * Buttons follow the text column.
 *
 * Once prose became a centred 640px column, a left-aligned button stayed
 * pinned to the 1245px container edge - on the Alaska page the closing CTA
 * sat at x=118 while every line of text above it began at x=421, which reads
 * as a stray element rather than the end of the essay. Found by looking at
 * the page, not by measuring it.
 *
 * Centred rather than aligned to the column's left edge: a button is not
 * prose, the homepage already centres its own CTA, and centring needs no
 * assumption about which column the button happens to sit in.
 * width:fit-content so the box stays the size of its label - a plain
 * margin:auto on an inline-block would do nothing.
 */
.kcb-style-wrap > .kcb-button {
	display: block;
	width: fit-content;
	margin-left: auto;
	margin-right: auto;
}


/*
 * REVERTED 2026-08-27 - do not re-add a blanket aspect-ratio here.
 *
 * This slot briefly carried `.kcb-image-grid__item { aspect-ratio: 3/2;
 * overflow: hidden }` (commit 601ed7b) to give the figure the definite height
 * that `.kcb-image-grid__image`'s `height:100%; object-fit:cover` needs before
 * it can crop at all. The diagnosis was right; the constant was not.
 *
 * 3/2 was chosen from the native frame shape of the two Alaska bodies, which
 * made it correct for exactly one grid on one page - and that block has since
 * been converted to a Gallery (Masonry), so nothing needed it any more. Every
 * image-grid actually in production is on /ten-years-later/, where the photos
 * are PORTRAIT 402x603 exports: forcing 3/2 cropped a 603px-tall frame into a
 * 268px-tall box and showed a horizontal strip of each one. Kedyn reported it
 * as "the grids on ten years later is broken now".
 *
 * "Now" is the other half of the lesson: the rule shipped earlier and sat
 * INVISIBLE behind the cached stylesheet. Bumping the plugin version for an
 * unrelated canvas.js fix busted that cache and exposed it. A CSS change is
 * not verified until a version bump has actually served it.
 *
 * Those grids align fine unforced - the exports are already uniform, so the
 * ragged-tile problem the rule was written for does not arise there. If a
 * future grid genuinely needs uniform crops, gate it on a modifier class the
 * renderer adds (e.g. when every image in that grid is landscape), never on
 * every .kcb-image-grid__item on the site.
 */

/* video-embed (2026-08-29). aspect-ratio instead of the classic padding-
 * bottom:56.25% hack - every browser this site supports has it (it is
 * already load-bearing in portfolio-grid.css's uniform-grid frames). */
.kcb-video-embed {
	max-width: 100%;
}

.kcb-video-embed iframe {
	display: block;
	width: 100%;
	aspect-ratio: 16 / 9;
	border: 0;
	background: #000;
}
