/* The Dogwood Woodwrights — site styles.
   Libre Caslon Display & Text, SIL OFL 1.1, self-hosted (assets/fonts/OFL.txt). */

@font-face {
  font-family: "Libre Caslon Display";
  src: url("../fonts/libre-caslon-display-400.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Libre Caslon Text";
  src: url("../fonts/libre-caslon-text-400.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Libre Caslon Text";
  src: url("../fonts/libre-caslon-text-400-italic.woff2") format("woff2");
  font-weight: 400; font-style: italic; font-display: swap;
}
@font-face {
  font-family: "Libre Caslon Text";
  src: url("../fonts/libre-caslon-text-700.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}

:root {
  --paper: #F6F3EC;
  --paper-2: #FBFAF6;
  --ink: #161815;
  --ink-soft: #3F443D;
  --green: #1F3B2D;
  --green-dark: #142A20;
  /* Two hairline tokens, deliberately NOT one. --rule is the decorative
     divider: section rules, the header underline, the callbar's top edge,
     list separators. It measures 1.35:1 on paper, which is correct for a
     line that carries no information. --rule-strong is the boundary of an
     interactive control — text inputs, the nav toggle, the lightbox
     buttons — where WCAG 1.4.11 requires 3:1 against
     the adjacent surface, because that border is what tells a low-vision
     visitor the control is there at all. Do not consolidate these back into
     one value: darkening --rule to satisfy 1.4.11 would repaint every
     divider on the site, and lightening --rule-strong would put the form
     fields back under the threshold. */
  --rule: #D9D3C4;
  --rule-strong: #8F8268;

  --measure: 68ch;
  --gutter: 1.25rem;
  --band-y: clamp(3.5rem, 8vw, 7rem);

  --display: "Libre Caslon Display", Georgia, "Times New Roman", serif;
  --body: "Libre Caslon Text", Georgia, "Times New Roman", serif;

  color-scheme: light;
}

@media (min-width: 64rem) { :root { --gutter: 5rem; } }

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--body);
  font-size: 1.125rem;
  line-height: 1.6;
  overflow-wrap: break-word;
  overflow-x: clip; /* never let a wide child scroll the page sideways */
}

img { max-width: 100%; height: auto; display: block; }

h1, h2, h3, h4 { font-family: var(--display); font-weight: 400; line-height: 1.06; margin: 0; }
h1 { font-size: clamp(2.5rem, 6.4vw, 5.25rem); letter-spacing: -0.01em; }
h2 { font-size: clamp(2rem, 4.4vw, 3.5rem); }
h3 { font-size: clamp(1.4rem, 2.4vw, 1.9rem); }
h4 { font-size: 1.25rem; }
p { margin: 0; max-width: var(--measure); }

a { color: var(--green); text-underline-offset: 0.18em; }
a:hover { color: var(--green-dark); }
:focus-visible { outline: 3px solid var(--green); outline-offset: 3px; }
/* The green ring is only legible on paper. Measured against the two dark
   surfaces the site actually puts focusable elements on: green-on-ink is
   1.46:1 and green-on-green is 1:1, i.e. the ring is invisible — and every
   page has five focusable elements in the dark footer (phone, email and the
   three social links). Switching the ring to paper on those surfaces gives
   16.12:1 on the ink footer and 11.01:1 on the green band, both well past the
   3:1 WCAG asks of a focus indicator. .band is included even though no band
   currently contains a link, because `.band a` is already styled for that
   case and the next band that gets one should not have to rediscover this. */
.band :focus-visible, .site-footer :focus-visible { outline-color: var(--paper); }

.skip {
  position: absolute; left: -9999px; top: 0;
  background: var(--green); color: var(--paper);
  padding: 0.75rem 1rem; z-index: 100;
}
.skip:focus { left: 0; }

.grid-12 > *, .stack > * { min-width: 0; }

.wrap { width: 100%; max-width: 80rem; margin-inline: auto; padding-inline: var(--gutter); }
.stack { display: flex; flex-direction: column; gap: var(--stack-gap, 1.5rem); }
/* .grid-12 is mobile-first on purpose: below 60rem every consumer's children
   are full-span (grid-column: 1 / -1) with no second item sharing a row, so
   the twelve tracks aren't doing anything visually yet — but a fixed
   column-gap would still charge eleven column-gaps against a spanning item's
   minimum width regardless of content (minmax(0, 1fr) floors the tracks at
   0, not the gaps). That's charged narrow viewports at large root font
   sizes before any text is even considered. Every current .grid-12 consumer
   (.pillars, .specialties, .service__inner) hits this the same way, so the
   fix belongs here, not patched per-consumer: one column and zero column
   gap below 60rem, twelve tracks and the real gap at and above it. Do not
   restore a column-gap below 60rem — it will reproduce this bug.
   (row-gap stays on for stacked children below the breakpoint.) */
.grid-12 { display: grid; grid-template-columns: minmax(0, 1fr); row-gap: 1.5rem; column-gap: 0; }
@media (min-width: 60rem) {
  .grid-12 { grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 1.5rem; }
}
.section { padding-block: var(--band-y); }
.rule { border: 0; border-top: 1px solid var(--rule); margin: 0; }

.band { background: var(--green); color: var(--paper); padding-block: var(--band-y); }
.band a { color: var(--paper); }
.band .eyebrow { color: var(--paper); opacity: 0.85; }
/* Same reason as .band above, for the other dark surface. Without this the
   footer's "Follow the work" inherits .eyebrow's --green on --ink: measured
   1.46:1, against AA's 4.5:1. The footer is not a .band, so the rule above
   never reached it. This is 11.8:1. */
.site-footer .eyebrow { color: var(--paper); opacity: 0.85; }

.eyebrow {
  font-family: var(--body); font-size: 0.875rem; font-weight: 700; letter-spacing: 0.18em;
  text-transform: uppercase; color: var(--green); margin: 0; line-height: 1.6;
}
/* .eyebrow sets its own font-family and line-height so it renders identically
   whether the element is a <p> (inherits the body's serif text font and 1.6
   line-height by default) or an <h2> (which would otherwise pick up the
   h1–h4 rule's display font and 1.06 line-height above). Used as an <h2> on
   the About page's #name and #letter sections so a screen-reader user has a
   real heading outline; the visual result must stay pixel-identical to the
   <p> version used elsewhere. */

.visually-hidden {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.lede { font-size: clamp(1.125rem, 1.6vw, 1.3125rem); color: var(--ink-soft); }

/* flex-wrap matters for any button whose label is more than one flex item.
   The home page's call button is `Call Mason - <span class="tel__num">…</span>`
   (see the .tel rules below), which makes two anonymous/real flex items in this
   inline-flex box. Without wrapping they share one line, and since the number
   is deliberately nowrap the pair could not fit a narrow viewport at a large
   root size: measured +11.46px past the viewport at 320px/root24 and +31.46px
   at 280px/root24. Wrapping lets the number take its own line inside the
   button. Single-label buttons (every other .btn) are one flex item and are
   unaffected. */
.btn {
  display: inline-flex; flex-wrap: wrap; align-items: center; justify-content: center;
  min-height: 44px; padding: 0.9rem 1.6rem;
  font-size: 1.0625rem; text-decoration: none;
  border: 1.5px solid var(--ink); color: var(--ink); background: transparent;
}
.btn--solid { background: var(--green); border-color: var(--green); color: var(--paper); font-weight: 700; }
.btn--solid:hover { background: var(--green-dark); border-color: var(--green-dark); color: var(--paper); }
.btn--quiet { border: 0; border-bottom: 1.5px solid var(--ink); padding-inline: 0.25rem; }

.graph-card {
  background-color: var(--paper-2);
  background-image:
    linear-gradient(#E7E2D6 1px, transparent 1px),
    linear-gradient(90deg, #E7E2D6 1px, transparent 1px);
  background-size: 22px 22px;
  box-shadow: 0 16px 40px rgba(20, 24, 20, 0.10);
  padding: clamp(2rem, 5vw, 4.5rem);
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}

.site-header { border-bottom: 1px solid var(--rule); background: var(--paper); }
/* flex-wrap is load-bearing twice over below 60rem, and neither case is
   cosmetic.
   (1) The open menu (.nav, width: 100%) is a flex item in this row. Without
   wrapping it could not take a line of its own, so opening the menu at 390px
   put a 129px-wide, 314px-tall column of links BESIDE the wordmark and
   squeezed the wordmark to 118px — measured, on every page.
   (2) At a large root font size the row's natural width far exceeds a phone
   viewport, and something has to give; wrapping lets the toggle drop to its
   own line at full size instead of being crushed.
   It is scoped to the mobile branch (nowrap is restored in the 60rem query
   below) because on the desktop header the same wrapping is a defect: .nav
   is a wide single row there, so between 60rem and ~1200px it dropped onto a
   second line — header height 153px instead of 94px at 1024px, with the nav
   left-aligned, since justify-content: space-between resolves to flex-start
   on a line holding one item. Measured broken at 960, 1024, 1100 and 1160px
   on all seven pages. Above 60rem the only thing nowrap costs is the
   wordmark scaling down (189.4px -> 178.4px at 1024px); it carries
   max-width: 100% and object-fit: contain, so it letterboxes rather than
   distorting, and the row stays one line with zero overflow. */
.site-header__inner { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1.5rem; padding-block: 1.25rem; }
/* Sized for the real logo, which is a COMPACT lockup (blossom + stacked text,
   4.42:1) where the old art was a long handwritten script (11.47:1). At the
   previous height the new mark rendered 124px wide at 390px against the old
   art's 321px — correct to the letter, and far too quiet. Set taller so the
   two carry comparable presence: 159px wide at 390px, 247px at 1440px. */
.wordmark img { height: clamp(2.25rem, 4.2vw, 3.5rem); width: auto; }

/* No-JS-first: below 60rem the nav is fully visible and stacked by default — a
   phone visitor with JavaScript off (or before it runs) can always reach every
   link. The .js class (set by an inline, render-blocking script in <head>, see
   the page markup) is what turns this into a collapsible menu; without it the
   toggle button never even appears. */
.nav {
  display: flex; flex-direction: column; gap: 0.25rem; padding-block: 1rem; width: 100%;
}
.nav a { color: var(--ink); text-decoration: none; padding: 0.75rem 0; min-height: 44px; display: inline-flex; align-items: center; }
.nav a[aria-current="page"] { border-bottom: 2px solid var(--green); }
/* `.nav a.nav__phone`, not `.nav__phone`: the `.nav a` rule directly above is
   (0,1,1) and this pill is (0,1,0), so `color: var(--green)` never applied and
   the phone rendered ink inside a green border. Matching the anchor explicitly
   raises this to (0,2,1) so the declared colour wins on its own terms — no
   !important, and the rule still only ever targets links inside the nav.
   Solid since Christina asked for the green background on review: paper
   #F6F3EC on green #1F3B2D measures 11.0:1, and the darker hover fill only
   raises it -- so the pill reads as the same control as .btn--solid, which
   is the pairing used for every other primary action on the site. */
.nav a.nav__phone {
  border: 1.5px solid var(--green); background: var(--green); color: var(--paper);
  font-weight: 700; padding: 0.7rem 1.1rem;
}
.nav a.nav__phone:hover { background: var(--green-dark); border-color: var(--green-dark); color: var(--paper); }

/* flex: none, deliberately overriding the blanket `min-width: 0` this button
   inherits from the shell overflow rule further down. That rule exists so a
   flex row can shrink its children instead of pushing past the viewport, but a
   button is the one child that must not shrink: its contents (the bars, the
   gap and the word "Menu") are a nowrap inline-flex row that does NOT clip, so
   shrinking the button just spills its own label out the side. At 390px/root24
   that measured as an 11px page overflow — hidden by body's overflow-x: clip,
   so documentElement.scrollWidth reported 401 against a 390px viewport while
   no element's bounding box looked wrong. With flex: none the button keeps its
   natural width and the row wraps instead. */
.nav-toggle {
  display: none;
  flex: none;
  align-items: center; gap: 0.5rem;
  min-height: 44px; padding: 0.5rem 0.75rem;
  font: inherit; color: var(--ink); background: transparent; border: 1px solid var(--rule-strong); cursor: pointer;
}
.nav-toggle__bars, .nav-toggle__bars::before, .nav-toggle__bars::after {
  display: block; width: 20px; height: 1.5px; background: var(--ink); content: "";
}
.nav-toggle__bars::before { transform: translateY(-6px); }
.nav-toggle__bars::after { transform: translateY(4.5px); }

@media (max-width: 59.99rem) {
  .js .nav-toggle { display: inline-flex; }
  .js .nav:not([data-open]) { display: none; }
  .js .nav[data-open] { display: flex; }
}

/* The gap is fluid and the labels never break, both because of the sixth link.
   With a flat 2.25rem gap the row does not fit between 960px and about 1200px:
   flex shrinks the widest item instead of overflowing, so "Holston Hills" was
   squeezed to 68px and broke onto two lines, making the header 30px taller than
   on every other width. Measured at 960/1000/1024/1080/1160/1200/1280/1440/1600
   before and after -- with these two declarations the label keeps its natural
   111px at every one of them and the header is a constant 95px. nowrap is what
   makes a future seventh link fail loudly (overflow) rather than quietly
   (a two-line label nobody notices on a laptop). */
@media (min-width: 60rem) {
  .site-header__inner { flex-wrap: nowrap; }
  .nav-toggle { display: none; }
  .nav { display: flex; flex-direction: row; align-items: center; gap: clamp(0.85rem, 2.1vw, 2.25rem); padding-block: 0; width: auto; }
  .nav a { white-space: nowrap; }
}

.site-footer { background: var(--ink); color: var(--paper); margin-top: var(--band-y); }
.site-footer__inner { display: flex; flex-wrap: wrap; gap: 2.5rem; justify-content: space-between; padding-block: 3.5rem 2rem; }
/* align-self, not a width: .site-footer__mark is a direct child of .stack, a
   column flexbox, so the default align-items: stretch stretches the IMG BOX to
   the full column width. height + object-fit: contain then letterbox the art
   inside that oversized box and centre it, which paints the mark 61px in from
   the text beneath it at 390px and 149px in at 1440px. Measured. The swap
   roughly doubled it, because the compact lockup draws far narrower than the
   old script did in the same box. Shrink the box to the art instead. */
.site-footer__mark { height: 3rem; width: auto; align-self: flex-start; }
.site-footer__line { font-size: 0.9375rem; opacity: 0.85; }
.site-footer a { color: var(--paper); }
/* The social row is three links (Facebook / Instagram / Houzz) that must fit
   whatever width is left over in the footer. It used to be a fixed
   `gap: 1.25rem` with no wrapping, which overflowed the viewport by +13.28px
   at 280px/root24 and +3.36px at 320px/root24: a rem gap grows with the root
   font size while the viewport does not, so two 30px gaps plus three
   zoomed-up labels were wider than the page. Two independent guards, both at
   the primitive: the row wraps, so a label that cannot fit moves to its own
   line instead of pushing the row wide; and the column gap is capped against
   the viewport with min(), so the gaps themselves can never be the thing that
   overflows. The link labels are the client's and are not shortened. */
.social { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; column-gap: min(1.25rem, 5vw); row-gap: 0.25rem; }
.social a { min-height: 44px; display: inline-flex; align-items: center; }
/* Telephone numbers must not break mid-number. html-validate's
   `tel-non-breaking` rule (html-validate:document) asks for U+2011 NON-BREAKING
   HYPHEN and U+00A0 in the displayed digits. U+2011 is NOT an option here: all
   four self-hosted Libre Caslon faces are Google's "latin" subset, and parsing
   each woff2's cmap shows U+00A0 present but U+2010 and U+2011 absent — every
   hyphen in every phone number would silently render in the Georgia fallback,
   and a U+2011 in the displayed text also breaks pasting the number into a
   dialer. The rule's own documented alternative is CSS: it skips any anchor
   whose class is listed in its `ignoreClasses` option, or that is styled
   `white-space: nowrap`. `.tel` is that class (see .htmlvalidate.json) and it
   is what actually keeps the digits together.

   `.tel--phrase` goes on any tel anchor whose label is WORDS rather than the
   bare number, and it restores normal wrapping. Two cases: the home page's
   "Call Mason - <span class=tel__num>865-978-0806</span>", where only the
   digits need protecting and nowrapping the whole sentence would push the
   button off a narrow screen; and the labels with no digits in them at all
   ("Call Mason" in the call bar, "call me" on the 404), where nowrap buys
   nothing and costs overflow — at 280px/root24 the call bar's "Call Mason"
   overhung its own button by 14.5px on each side (label box [0.5..148.6]
   against a button box of [15..134]) and collided with "Send a note" by
   2.6px. Those anchors keep the `tel` token so html-validate's
   `ignoreClasses` still applies — the rule is flagging the space in a
   two-word label that contains no telephone number, which is a false
   positive — but the class must not also impose nowrap on them. */
.tel, .tel__num { white-space: nowrap; }
.tel--phrase { white-space: normal; }

.site-footer__legal { border-top: 1px solid rgba(246, 243, 236, 0.25); padding-block: 1.25rem 2rem; font-size: 0.875rem; opacity: 0.7; }

/* display:none is the default and the only state without .js — an author-origin
   display declaration beats the UA's `[hidden] { display: none }` regardless of
   specificity, so relying on the `hidden` attribute alone (as this rule used to)
   silently never hid the bar. .callbar[hidden] is kept explicit for defense in
   depth, but .js .callbar:not([hidden]) is the only rule that ever reveals it —
   deliberately: a no-JS visitor never sees this bar (the header already carries
   the phone number, and a permanent undismissable fixed bar would be worse than
   not having it), and it's only ever shown/hidden by site.js clearing/restoring
   the `hidden` attribute once past the scroll threshold. */
.callbar {
  position: fixed; inset-inline: 0; bottom: 0; z-index: 50;
  display: none; gap: 0.5rem; padding: 0.625rem;
  background: var(--paper); border-top: 1px solid var(--rule);
}
.callbar .btn { flex: 1; }
.callbar[hidden] { display: none; }

@media (max-width: 59.99rem) {
  .js .callbar:not([hidden]) { display: flex; }
}

@media (min-width: 60rem) { .callbar { display: none !important; } }

/* Below 60rem the callbar is a fixed bar the JS shows once the visitor scrolls
   past 600px. Reserve that space at the foot of the page so it never sits over
   the footer's last line at the bottom of the scroll. --callbar-h is measured
   and kept current by site.js (ResizeObserver + resize listener) — never a
   guessed constant, since button copy can wrap to two lines and change the
   bar's real height. It reads 0px whenever the bar is actually display:none
   (JS off, or JS on but not yet scrolled past the threshold, or scrolled back
   above it) since offsetHeight of a display:none element is 0 — which is
   exactly correct: no reservation is needed for a bar that isn't rendered. */
@media (max-width: 59.99rem) {
  .site-footer__legal { padding-bottom: calc(var(--callbar-h, 0px) + 1rem); }
}

/* Overflow protection for the shell's flex rows (header/footer/nav/callbar/social):
   flex items default to min-width:auto, which floors their shrink at their content's
   natural size and can push the row past the viewport. The .grid-12/.stack rule above
   doesn't cover these rows, so they get their own. */
.site-header__inner > *, .site-footer__inner > *, .nav > *, .callbar > *, .social > * { min-width: 0; }
.wordmark img, .site-footer__mark { max-width: 100%; object-fit: contain; }

/* …except the desktop nav, which must be exempt from the rule directly above.
   min-width: 0 lets a flex item shrink past its own content; for a row of text
   links that does not make them narrower, it makes their text render OUTSIDE
   their boxes, because an inline-flex anchor has overflow: visible and does not
   clip. Measured on services.html at 1440px with a browser default font size of
   24px — a real configuration, since Chrome's default-font setting moves the
   root size and the media-query basis together, putting 60rem at 1440px — the
   links' text escaped its boxes by 3.5px and the aria-current underline, which
   is sized to the box, stopped 3.5px short of the word it underlines. (An
   earlier note here said 26.7px. That came from a harness that set the root
   font size WITHOUT moving the media-query basis with it — a state no real
   browser produces. Re-measured under coupled emulation it is 3.5px: the
   defect is real, the original figure was an artefact.)

   Both the nav and its children need the exemption. Exempting only the children
   moves the overflow up a level instead of removing it: the links keep their
   size, the nav box does not. NOTE: the 21.2px viewport overflow once cited
   here for that variant does NOT reproduce under coupled emulation — do not
   cite it. Keep both exempt anyway: it is the only variant measured clean on
   every axis. If you ever narrow this rule, re-measure rather than trusting
   that number. With both exempt the nav keeps its natural width and the
   wordmark absorbs the pressure, which it is built to do — max-width: 100% plus
   object-fit: contain letterboxes it rather than distorting it (drawn aspect
   stays 4.4192, its natural ratio, in every cell measured).

   Cost to the wordmark. Do NOT assume the narrowest desktop viewport is the
   worst case — it is not. --gutter steps from 1.25rem to 5rem at 64rem (see
   :root above), removing ~120px of content width across a single pixel of
   viewport growth, which digs a second trough just past that step.

   Measured on DEFAULT settings (root 16, no emulation): the floor is at the
   60rem edge, 178.14 x 40.31px at 960px, where the box is uncompressed. The
   gutter trough is shallower than the old art's: 1022px -> 189.36px, then
   1024px -> 178.39 x 40.37 (box squashed to 4.149, letterboxed), recovering
   by 1040. Other browser defaults: root20 222.67 x 50.39 at 1200 (trough
   227.25 at 1280); root24 267.22 x 60.47 at 1440 (trough 280.09 at 1540).

   NOTE: an earlier owner sign-off set a 271.3px stop-threshold. That number
   does NOT transfer — it was measured against the retired 11.47:1 handwritten
   script. The real logo is a 4.42:1 lockup, so it is narrower at a given
   height by construction: at the floor it is 40.31px TALL against the old
   art's 25.8px, 56% more height. Judge this mark by height, and re-measure
   rather than comparing widths across two different shapes.
   If you change --gutter or its breakpoint, re-measure at the new 64R.

   This must stay scoped to the desktop branch: below 60rem the nav is a full
   width stacked column and min-width: 0 is still doing real work there. Source
   order matters too — both selectors are (0,1,0), so this block has to stay
   BELOW the rule it overrides. */
@media (min-width: 60rem) {
  .nav, .nav > * { min-width: auto; }
}

/* Home page — hero, pillars, work grid, letter, specialties, closing. */

/* .hero__inner's two direct children (.hero__text, .hero__art) aren't covered
   by the sitewide `.grid-12 > *, .stack > *` min-width: 0 rule (this grid isn't
   classed .grid-12), so under root-font-size zoom at narrow widths their own
   min-content size could otherwise blow the row past the viewport. */
.hero__inner { display: grid; gap: clamp(2rem, 5vw, 4rem); }
.hero__inner > * { min-width: 0; }
.hero__text { --stack-gap: 1.75rem; }
.hero__actions { display: flex; flex-wrap: wrap; gap: 1rem; }
/* Unused since 2026-09-26: Mason asked for the "Licensed General Contractor"
   line to come off every page until he holds the licence (exam 2026-11-05).
   The rule stays because the line returns with the number -- see the licence
   row in docs/copy-review.md. */
.hero__license { font-size: 0.875rem; color: var(--ink-soft); }
/* --sketch-w is the single source of truth for the sketch's box width, shared
   with the caption below so the caption's horizontal reservation always tracks
   the sketch's actual size instead of an independent guess. .hero__sketch also
   carries `max-width: 40%`, which can only ever shrink it further (never grow
   it past --sketch-w), so sizing the caption's reservation off --sketch-w alone
   is always a safe (conservative) upper bound on the sketch's real width. */
.hero__art { position: relative; container-type: inline-size; --sketch-w: clamp(7rem, 16vw, 11rem); }
/* Two rotated, absolutely-positioned cards both used to fight over the frame's
   bottom-left corner (see git history). The fix here makes that class of bug
   structurally impossible rather than merely unlikely: the caption is back in
   normal flow, below the photo, inside the frame's own padding — it can never
   sit on top of the photograph, at any text size, because flow layout puts it
   after the image, not because of a distance we measured once. Horizontal
   clearance from the sketch (which stays absolutely positioned, pinned to the
   frame's lower-left — the approved composition) comes from a padding-left on
   the caption sized off --sketch-w, not a percentage of the frame. */
.photo-pin { position: relative; margin: 0; background: #fff; padding: 0.875rem 0.875rem 1.125rem; box-shadow: 0 18px 40px rgba(20, 24, 20, 0.16); rotate: 1.5deg; max-width: 100%; }
/* padding-left reserves space at least as wide as the sketch's rotated bounding
   box (--sketch-w inflated ~8% for the -3deg rotation's bounding-box growth)
   plus a firm 1rem of clearance beyond that (comfortably over the required
   0.75rem minimum). Because --sketch-w is expressed in rem/vw, this reservation
   scales correctly under root-font-size text zoom, not just viewport width. At
   narrow container widths this can still force the caption to wrap onto a
   second line — fine, the frame is in flow and simply grows to fit it. Below
   22rem (see the @container rule below) the reservation is dropped entirely,
   since two lines stops being achievable there. */
.photo-pin figcaption { margin: 1.75rem 0 0; padding-left: calc(var(--sketch-w) * 1.08 + 1rem); text-align: right; font-style: italic; font-size: 0.9375rem; color: var(--ink-soft); }
.hero__sketch { position: absolute; left: 0; bottom: -1.5rem; width: var(--sketch-w); max-width: 40%; background: var(--paper-2); padding: 0.75rem; box-shadow: 0 12px 28px rgba(20, 24, 20, 0.18); rotate: -3deg; }

/* Below a CONTAINER width (not viewport — .hero__art is the query container,
   so this catches both the phone-narrow case and the desktop two-column
   layout's own narrower .hero__art measure with one rule, since both produce
   a narrow container) there isn't room to reserve horizontal space for the
   sketch AND fit "Mason Simpson, woodwright" in two lines while keeping the
   sketch a visible size and the required 0.75rem clearance — the text alone
   needs ~150-180px depending on root font-size zoom, which can exceed the
   entire frame at narrow-width-plus-zoom combinations. Rather than shrinking
   the sketch toward invisibility to chase that horizontally, pull its bottom
   edge up so it stays within the photograph itself and never reaches down
   into the caption's row, and let the caption use the frame's full width.
   They then no longer share a vertical band, so they cannot collide
   regardless of horizontal position — a structural guarantee, not a measured
   distance. (7rem of lift comfortably clears a two-line caption: ~1.75rem
   margin-top + ~2 lines at ~1.5rem line-height + ~1.125rem padding-bottom,
   all rem-based so they scale with root font-size the same as this offset.)
   22rem is not a guess: a full 216-cell sweep (72 widths x 16/20/24px root)
   found the worst case where plain reservation still produces 3 lines at a
   .hero__art width of 20.9rem (root 24px, 1520px viewport — the desktop
   column stays narrow while sketch-w and the text both grow with the zoomed
   root) — 22rem clears that with margin. Re-run the sweep before changing
   this number. */
@container (max-width: 22rem) {
  .hero__sketch { bottom: 7rem; }
  .photo-pin figcaption { padding-left: 0; }
}

/* .hero__actions and .closing__actions are inline flex rows of buttons; they
   already wrap (flex-wrap: wrap) so a long pair never forces horizontal
   overflow, but each button still gets min-width: 0 so it can shrink below
   its own text's natural width on very narrow screens instead of flexing the
   row wider than the viewport. */
.hero__actions > *, .closing__actions > * { min-width: 0; }

@media (min-width: 60rem) {
  .hero__inner { grid-template-columns: 7fr 5fr; align-items: center; }
}

.pillars__question { grid-column: 1 / -1; }
.pillars__body { grid-column: 1 / -1; --stack-gap: 2rem; }
.pillars__answer { font-size: clamp(1.1875rem, 1.8vw, 1.375rem); max-width: var(--measure); }
.pillars__list { list-style: none; margin: 0; padding: 1.75rem 0 0; border-top: 1px solid rgba(246, 243, 236, 0.35); display: grid; gap: 1.5rem; }
.pillars__list h3 { font-family: var(--body); font-size: 1.125rem; font-weight: 700; }
.pillars__list p { font-size: 1rem; opacity: 0.9; }

@media (min-width: 60rem) {
  .pillars__question { grid-column: span 5; }
  .pillars__body { grid-column: 7 / span 6; }
  .pillars__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

.work-grid__head { display: flex; flex-wrap: wrap; gap: 1rem; justify-content: space-between; align-items: baseline; }
.work-grid__head > * { min-width: 0; }
.work-grid__items { display: grid; gap: 2rem; }
.work-grid figure { margin: 0; display: flex; flex-direction: column; gap: 0.875rem; min-width: 0; }
.work-grid img { aspect-ratio: 4 / 5; object-fit: cover; width: 100%; }
.work-grid figcaption { font-size: 1rem; }
.work-grid figcaption i { color: var(--ink-soft); }

@media (min-width: 48rem) { .work-grid__items { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 72rem) { .work-grid__items { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

.letter .graph-card { --stack-gap: 1.5rem; max-width: 60rem; margin-inline: auto; }
.letter p { font-size: clamp(1.1875rem, 1.9vw, 1.5rem); line-height: 1.7; max-width: none; }
.letter__signature { width: min(18.75rem, 70%); }

.specialties__intro { grid-column: 1 / -1; --stack-gap: 1.25rem; }
.specialties__list { grid-column: 1 / -1; list-style: none; margin: 0; padding: 0; }
.specialties__list li { display: flex; gap: 1.75rem; align-items: baseline; padding-block: 1.375rem; border-bottom: 1px solid var(--rule); font-size: clamp(1.25rem, 2.2vw, 1.625rem); }
.specialties__list li > * { min-width: 0; }
.specialties__list span { font-family: var(--body); font-size: 0.9375rem; font-weight: 700; color: var(--green); }
.specialties__list a { color: var(--ink); text-decoration: none; }
.specialties__list a:hover { text-decoration: underline; }

@media (min-width: 60rem) {
  .specialties__intro { grid-column: span 4; }
  .specialties__list { grid-column: 6 / span 7; }
}

.closing { text-align: center; border-top: 1px solid var(--rule); }
.closing .stack { align-items: center; --stack-gap: 1.75rem; }
.closing__quote { font-family: var(--display); font-size: clamp(1.75rem, 4.4vw, 3.75rem); line-height: 1.12; max-width: 62rem; }
.closing__actions { display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center; }

.page-head { padding-bottom: 0; }
.page-head .stack { --stack-gap: 1.25rem; }

.service { padding-block: var(--band-y); border-bottom: 1px solid var(--rule); }
.service__inner { align-items: center; }
.service__text { grid-column: 1 / -1; --stack-gap: 1.125rem; }
.service__photo { grid-column: 1 / -1; margin: 0; }
.service__photo img { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; }
/* A photo that must be seen whole -- the corner window's head casing is the
   point of it (Christina, review 2026-10-11) -- keeps its own shape. */
.service__photo--whole img { aspect-ratio: auto; }
.service__photo figcaption { font-size: 0.9375rem; color: var(--ink-soft); padding-top: 0.625rem; }
.service--full .service__text { max-width: 48rem; margin-inline: auto; }

/* Flip class: the brief's `.service:nth-of-type(even)` counted every
   sibling <section> in <main>, not just `.service` ones. Because .page-head
   is the first <section>, that shifted parity by one and flipped the wrong
   sections (mantles, the intended un-flipped lead item, would have matched
   "even"). A `.service--flip` class on the sections that should alternate
   documents intent in the markup itself, is correct regardless of what
   precedes the sections in the DOM, and — unlike hardcoded ids — won't
   silently skip a ninth service that also needs to flip. */
@media (min-width: 60rem) {
  .service__text { grid-column: span 6; }
  .service__photo { grid-column: 8 / span 5; }
  .service--flip .service__text { grid-column: 7 / span 6; order: 2; }
  .service--flip .service__photo { grid-column: 1 / span 5; order: 1; }
}

/* About page — story, name origin, signed letter. */
.about__photo { grid-column: 1 / -1; margin: 0; }
.about__photo img { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; }
.about__photo figcaption { font-size: 0.9375rem; color: var(--ink-soft); padding-top: 0.625rem; }
.about__text { grid-column: 1 / -1; --stack-gap: 1.25rem; }
.about__list { margin: 0; padding-left: 1.25rem; color: var(--ink-soft); }
.about__list li { padding-block: 0.25rem; }

.name__sketch { grid-column: 1 / -1; width: clamp(8rem, 22vw, 14rem); }
.name__text { grid-column: 1 / -1; --stack-gap: 1.25rem; }
.name__body { font-family: var(--display); font-size: clamp(1.5rem, 3vw, 2.125rem); line-height: 1.35; max-width: none; }

@media (min-width: 60rem) {
  .about__photo { grid-column: span 5; }
  .about__text { grid-column: 7 / span 6; }
  .name__sketch { grid-column: span 3; justify-self: center; }
  .name__text { grid-column: 5 / span 8; }
}

/* Gallery page — grouped by the kind of work (Christina, review 2026-10-10),
   in the Services page's order. Each type is a section; inside it, a job with
   a write-up is an <article class="job"> and loose photos sit in a plain grid.

   Phones get one photo per row: most visitors are on one, and a door or a
   mantle reads better full width than as a half-width thumbnail. From 40rem
   the photos sit on a three-column grid; from 60rem the type name moves into
   a left column and stays pinned while its photos scroll past.

   Every grid's spans add up to whole rows of three — the job's photos are
   chosen per grid in the markup, so there is no orphan to tidy up here:
     (default)  one column, 4:5
     --wide     two columns at 5:3, which is the same height as a 4:5
                single beside it (two columns plus a gap, over 5:3, comes
                within a few pixels of one column over 4:5)
     --full     all three columns, 3:2
     --solo     a lone photo across two columns at its own proportions,
                capped so a tall one never takes over the page
     --pair     a grid of exactly two equal photos, as halves */

.gallery__index ul {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; column-gap: 1.5rem;
}
.gallery__index a {
  display: inline-block; padding-block: 0.625rem; /* a 44px target */
  font-family: var(--display); font-size: 1.375rem; line-height: 1.1;
  color: var(--green); text-decoration-thickness: 1px; text-underline-offset: 0.25em;
}

.gtype { padding-block: var(--band-y); border-top: 1px solid var(--rule); }
.gtype__inner { display: grid; grid-template-columns: minmax(0, 1fr); row-gap: clamp(1.5rem, 4vw, 2.5rem); }
.gtype__name {
  color: var(--green); font-size: clamp(2.75rem, 11vw, 4.5rem); line-height: 1;
  letter-spacing: -0.01em;
}
.gtype__body { display: flex; flex-direction: column; gap: clamp(3rem, 7vw, 4.5rem); }

.job { display: flex; flex-direction: column; gap: 1rem; }
.job > p { color: var(--ink-soft); max-width: 38rem; margin-bottom: 0.5rem; }

.gallery__grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2rem; }
.gallery__item { margin: 0; display: flex; flex-direction: column; gap: 0.625rem; }
.gallery__item img { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; }
.gallery__item--wide img { aspect-ratio: 5 / 3; }
.gallery__item--full img { aspect-ratio: 3 / 2; }
.gallery__item--solo img { width: auto; max-width: 100%; aspect-ratio: auto; max-height: min(85vh, 36rem); }
.gallery__item--solo a { align-self: start; }
.gallery__item figcaption { font-size: 0.9375rem; color: var(--ink-soft); }
/* Clips keep their own 16:9 and take two columns, so a saw or a router reads
   at a useful size instead of being cropped to the photographs' 4:5. They
   are silent (ingest drops the audio) and load nothing until played. None
   are on the site at the moment; the rule stays for when they come back. */
.gallery__item video { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; background: var(--ink); }

@media (min-width: 40rem) {
  .gallery__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.75rem 1.25rem; align-items: start; }
  .gallery__grid--pair { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .gallery__item--wide, .gallery__item--solo, .gallery__item--video { grid-column: span 2; }
  .gallery__item--full { grid-column: 1 / -1; }
}

@media (min-width: 60rem) {
  .gtype__inner { grid-template-columns: minmax(0, 4fr) minmax(0, 8fr); column-gap: 3rem; align-items: start; }
  /* Pinned beside its photos; the header does not stick, so 2rem is clear. */
  .gtype__name { position: sticky; top: 2rem; font-size: clamp(2.5rem, 3.6vw, 3.5rem); }
  .gtype__name::after {
    content: ""; display: block; width: 2.5rem; height: 2px; margin-top: 1.25rem;
    background: var(--green);
  }
}

.lightbox { width: min(92vw, 68rem); max-width: none; border: 0; padding: 0.75rem; background: var(--paper); }
.lightbox::backdrop { background: rgba(22, 24, 21, 0.85); }
.lightbox img { width: 100%; max-height: 78vh; object-fit: contain; }
.lightbox__caption { font-size: 0.9375rem; color: var(--ink-soft); padding: 0.75rem 0.25rem 0; max-width: none; }
.lightbox__close, .lightbox__nav button {
  min-width: 44px; min-height: 44px; font: inherit; font-size: 1.25rem;
  background: var(--paper); color: var(--ink); border: 1px solid var(--rule-strong); cursor: pointer;
}
.lightbox__close { position: absolute; top: 0.75rem; right: 0.75rem; }
.lightbox__nav { display: flex; gap: 0.5rem; padding-top: 0.5rem; }

/* Contact page — form + call/write/service-area aside.
   .contact__inner carries .grid-12: below 60rem it stays the contract's plain
   single column (grid-12's own row-gap: 1.5rem, column-gap: 0), so only
   row-gap is widened here at every width. column-gap is widened separately,
   inside the 60rem query only, and only with the longhand property — never
   the `gap` shorthand on anything carrying .grid-12, which would reassign
   both row-gap and column-gap and reopen the zero-column-gap-below-60rem bug
   documented above .grid-12 itself. */
.contact__inner { row-gap: clamp(2rem, 5vw, 4rem); }
.contact__form { grid-column: 1 / -1; --stack-gap: 1.25rem; }
.contact__aside { grid-column: 1 / -1; --stack-gap: 2rem; }
/* :not(.eyebrow) because this rule is (0,1,1) and .eyebrow is (0,1,0), so it
   was overriding the label size and the categories rendered as 24px tracked
   caps instead of a 14px label. Same trap as `.nav a` vs `.nav__phone` and
   `.field label` vs `.check`. The plain size is still needed for the Apply
   page's "Looking to have work done?", which is a sentence, not a category. */
.contact__aside h2:not(.eyebrow) { font-size: 1.5rem; }
/* The 2rem --stack-gap above is inherited by each inner group, so a category
   label sat exactly as far from its own answer as from the next category and
   the column read as six unrelated lines. Christina caught it on review.
   The labels themselves now carry .eyebrow, which line 135 was already built
   to render identically on an <h2> as on a <p> -- no new type treatment. */
.contact__aside > div.stack { --stack-gap: 0.4rem; }
/* The outer min() caps this against the viewport, and only ever bites on a
   very narrow screen at a large root font size: 42px -> 35px at 280px/root24,
   42px -> 40px at 320px/root24, identical to the plain clamp() at every other
   width/root combination measured (280/320/390/768/1440 x 16/20/24). It is
   needed because .tel makes the number a single unbreakable token, and the
   clamp()'s lower bound is in rem, so at root 24 it stayed 42px on a 280px
   screen and ran 1.34px past the viewport. */
.contact__phone { font-family: var(--display); font-size: min(clamp(1.75rem, 4vw, 2.5rem), 12.5vw); text-decoration: none; }

.field { display: flex; flex-direction: column; gap: 0.4rem; margin: 0; }
.field label { font-size: 0.9375rem; font-weight: 700; }
.field span[aria-hidden] { color: var(--green); }
.field input, .field textarea, .field select {
  font: inherit; font-size: 1.0625rem; color: var(--ink);
  background: var(--paper-2); border: 1px solid var(--rule-strong);
  padding: 0.75rem 0.875rem; min-height: 44px; width: 100%;
}
.field textarea { min-height: 9rem; resize: vertical; }
.field input:focus-visible, .field textarea:focus-visible, .field select:focus-visible { border-color: var(--green); }
.field input[aria-invalid="true"], .field textarea[aria-invalid="true"] { border-color: #B3261E; }
.field__note { font-size: 0.9375rem; color: var(--ink-soft); }

/* Inline, per-field error text: announced via aria-live (in the markup, not
   here) rather than communicated by color alone. Collapses to nothing when
   empty so an unerrored field reserves no extra vertical space. */
.error-text { display: block; font-size: 0.875rem; color: #B3261E; }
.error-text:empty { display: none; }

/* Checkbox group (apply.html). A <fieldset> carries a UA border, margin and
   padding that nothing else on this form has, so they are reset here rather
   than left to look like a stray box in the middle of the stack. The .field
   input/textarea/select rule above deliberately does NOT reach these: it sets
   width: 100% and a 44px min-height, which on a checkbox would render a
   full-width square.

   The 44px touch target lives on the LABEL, not the box. Each label wraps its
   own control, so the whole "checkbox + word" strip is the hit area -- which is
   why the markup uses wrapping labels rather than label[for]. */
.field--group { border: 0; margin: 0; padding: 0; min-width: 0; }
.field--group legend { padding: 0; font-size: 0.9375rem; font-weight: 700; }
.checks {
  display: flex; flex-wrap: wrap; gap: 0.25rem 1.5rem;
  margin: 0.5rem 0 0;
}
/* `.field--group .check`, not `.check`: these labels sit inside .field, so the
   `.field label` rule above is (0,1,1) and a bare `.check` is (0,1,0) — the
   font-size and weight here never reached the page, and all eight trades
   rendered at 15px/700, indistinguishable from the real field labels above and
   below them. Measured. Exactly the same trap as `.nav a` vs `.nav__phone`
   documented further up this file; same fix, raise the selector, no !important. */
.field--group .check {
  display: inline-flex; align-items: center; gap: 0.5rem;
  min-height: 44px; font-size: 1.0625rem; font-weight: 400;
  cursor: pointer;
}
.field--group .check input {
  width: 1.125rem; height: 1.125rem; min-height: 0; flex: none;
  accent-color: var(--green); margin: 0;
}

.form-status { font-size: 0.9375rem; }
.form-status:empty { display: none; }
.form-status[data-state="error"] { color: #B3261E; font-weight: 700; }
.form-status[data-state="notice"] { color: var(--ink-soft); }

/* The honeypot is hidden with the `hidden` attribute (and tabindex="-1" +
   autocomplete="off" as belt-and-suspenders) rather than aria-hidden/CSS
   alone, so it is removed from layout, the accessibility tree, and the tab
   order all at once. Per the callbar bug above, an author rule targeting a
   `hidden` element must restate `display: none` explicitly — the offscreen
   position/overflow rules below don't set `display` themselves, so they
   don't fight the UA default, but this rule is kept anyway for defense in
   depth if the `hidden` attribute is ever removed by mistake. */
.honeypot { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.honeypot[hidden] { display: none; }

@media (min-width: 60rem) {
  .contact__inner { column-gap: clamp(2rem, 5vw, 4rem); }
  .contact__form { grid-column: span 7; }
  .contact__aside { grid-column: 9 / span 4; }
}
