/* TechTribe Unlimited - static site stylesheet */
/* ---------- fonts (self-hosted, IBM Plex, SIL OFL 1.1) ----------
   Design's choice, 2026-07-28. Two families from one superfamily, drawn on the
   same skeleton, so nothing needs harmonising. Served from our own assets: a
   webfont fetched from Google or a CDN is a third party watching every visitor
   and a single point of failure for the page's text.

   The -macron cuts carry the ten Maori macron glyphs only. The full latin-ext
   would have been 44 KB and blown the budget; the site has zero latin-ext
   characters today, but an NZ site will meet a macron sooner or later and must
   not drop to a system font mid-word. unicode-range means these files are only
   fetched when such a character actually appears. */
@font-face {
  font-family: 'IBM Plex Sans'; font-style: normal; font-weight: 100 700; font-display: swap;
  src: url('../fonts/ibm-plex-sans-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'IBM Plex Sans'; font-style: normal; font-weight: 100 700; font-display: swap;
  src: url('../fonts/ibm-plex-sans-macron.woff2') format('woff2');
  unicode-range: U+0100-0101, U+0112-0113, U+012A-012B, U+014C-014D, U+016A-016B;
}
@font-face {
  font-family: 'IBM Plex Mono'; font-style: normal; font-weight: 600; font-display: swap;
  src: url('../fonts/ibm-plex-mono-600-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'IBM Plex Mono'; font-style: normal; font-weight: 600; font-display: swap;
  src: url('../fonts/ibm-plex-mono-600-macron.woff2') format('woff2');
  unicode-range: U+0100-0101, U+0112-0113, U+012A-012B, U+014C-014D, U+016A-016B;
}

:root {
  /* Tells the browser its OWN chrome is dark, which no rule of ours can reach: the native
     select's dropdown, scrollbars, date and file pickers, and -- the one that matters -- the
     autofill background. Without it a returning visitor opens the request form and sees their
     saved name and email in pale yellow blocks on a dark card, and only THEY see it: autofill
     needs a previous visit, so it survives every test we run and appears for real customers
     only. Design found it, 2026-08-03, and nothing on 42 pages declared it. */
  color-scheme: dark;
  /* WORKSHOP. Dark and matte, square, depth from edges. The eleven names below are the ones the
     rest of the file already uses -- 184 times -- so this block re-points them rather than
     renaming anything.

     Every value that carries text was measured against the LIGHTEST surface a visitor can see it
     on (--surface-2), not against the page: what passes on the page can still fail on a raised
     card. Small text 4.5:1, boundaries that signal state 3.0:1. Hairlines are deliberately below
     both -- they group things, they do not tell you anything, and holding them to a threshold
     would be measuring the wrong thing. */
  --accent: #ffab2e;                    /* the action accent. Buttons, prices, focus. 8.20 on --surface-2 */
  --accent-deep: #c97f10;
  --accent-soft: rgba(255,171,46,0.14);
  /* The button face: one hue (36 degrees), five steps of lightness, of which --accent and
     --accent-deep already existed. The three new ones were added under the family's previous
     name so the rename that followed would carry them along instead of leaving three strays.
     Contrast against the label --text-on-accent, worst point first: --accent-sink 6.82 (the
     hover bottom stop, and the worst the button ever gets), --accent-mid 8.27, --accent-lit 11.30. */
  --accent-lit: #ffbe5c;                /* gradient top at rest */
  --accent-mid: #f09a1c;                /* gradient bottom at rest */
  --accent-sink: #d98c14;               /* gradient bottom on hover */
  /* Two jobs left, and they are the only two: "what you just did worked". Everything decorative
     that used to be mint is now either the accent or nothing. */
  /* Success, and nothing else. Design's value: the panel's #7DF3C4 was a near-duplicate of
     the brand mint, so the token survives and the literal does not. 11.35 worst case. */
  --mint: #48F8B8;
  --mint-deep: #48F8B8;
  --ok: #48F8B8;
  --ink: #eceef2;                     /* 13.33 */
  /* Moved even though it never failed: the quiet level below it moved up, and at #9a9da8 the
     two sat 1.04 apart -- one level of the hierarchy wearing two names. 8.07 worst case. */
  --ink-soft: #b8bbc4;
  /* The LIGHT theme's own old value, unchanged: the grey that was too faint on white (2.61)
     is right on dark. 5.95 worst case, and it absorbs the #6B7080 the light theme used for
     placeholders and small print. */
  --ink-faint: #9aa0b4;
  --bg: #141416;                      /* the page */
  --bg-soft: #1b1c1f;
  --border: #33343a;                  /* hairline */
  --radius: 0;                        /* square, everywhere */
  --shadow: none;                     /* depth comes from edges now */

  /* The surfaces. These are new because they had to be: "white" was written by hand in 21 places
     and a page cannot be re-skinned one hard-coded background at a time. */
  --surface: #1c1d21;                 /* a card */
  --surface-2: #232429;               /* the lit top of a card, and the worst case for text */
  --line: #3a3b43;                    /* a card's own edge, 1px */
  --text-on-accent: #1a1200;          /* 9.83 on the amber */
  --plate: #f3f2f0;
  /* A field is defined by its BORDER, never by its fill: the fill is always darker than the card
     it sits in, so an input can never become a white block on a dark page. The fill separates
     nothing (1.05 against the card) and does not need to -- the border is 4.46 against the card
     and 4.70 against the fill. */
  --field: #141416;   /* a field is always DARKER than its card */
  --field-line: #7C8398;
  /* The outlined button's face, taken from the W4 sample as Peter asked for it (2026-08-04):
     the same worked plane as the primary, one register darker. The fill barely separates from
     the surfaces it sits on -- that is expected and not what carries it; the bright edge below
     does. Keep the pair together: a lighter fill without the bright edge is an invisible button. */
  --sunk: #2a2b31;          /* gradient top */
  --sunk-deep: #212227;     /* gradient bottom */
  --line-bright: #585a66;   /* the outlined button's edge, and the only thing that announces it */
  /* An error has to stay legible on the lightest card, and it has to outrank the thing it is
     warning about. #FF9A9A cleared the first test at 7.63 and failed the second: it sits
     directly under the amber submit button and was only 1.08 apart from it in lightness, so the
     warning read as quieter than the control it warns about. 5.12 on the lightest face, 1.60
     from the amber, and it differs in hue as well as in weight. */
  --danger: #ff5c5c;
}
* { box-sizing: border-box; }
/* The header is sticky, so an anchor target would otherwise land underneath it and
   arrive with its heading hidden. Measured: the header is 69px on desktop and 65px
   on mobile, so 88px clears it with room to breathe. This sits on the scroll
   container rather than on individual targets, so it covers every anchor on the
   site, including ones added later. */
html { scroll-behavior: smooth; scroll-padding-top: 88px; }
body {
  margin: 0;
  font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  color: var(--ink);
  background: var(--bg);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
.wrap { max-width: 1140px; margin: 0 auto; padding: 0 24px; }

/* One ring for everything focusable. :where() has zero specificity, so the rules further down
   that already spell out a focus ring keep winning -- this only fills the gaps, which were
   .btn, .nav a, .site-footer a, .product-card a and .filter-btn. Without it those controls get
   the browser's own ring: white and ROUNDED, on a site where nothing is rounded, and visible
   only to someone navigating by keyboard -- the person least likely to be asked.
   Amber because a focus ring marks where you can act, which is what the accent means. */
:where(a, button, [tabindex]):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 0;
}

/* ---------- header ---------- */
.site-header {
  position: sticky; top: 0; z-index: 100;
  background: linear-gradient(180deg, rgba(35,36,41,0.96), rgba(20,20,22,0.96));
  backdrop-filter: blur(8px);
  border-bottom: 1px solid var(--line);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.07);
}
.nav { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 12px 24px; max-width: 1140px; margin: 0 auto; }
.brand { display: flex; align-items: center; flex-shrink: 0; }
.brand img { height: 40px; width: auto; }

/* Short viewports: landscape phones, and any in-app browser (Telegram, Facebook, Instagram)
   where the host app's own URL bar and toolbar already overlay the top and bottom of the
   viewport. Measured 2026-08-23 on a landscape phone (844x390): the header is 73px, which is
   19 percent of the screen, and being sticky it covers that strip at EVERY scroll position.
   Peter photographed exactly this: the ribbon sitting mid-screen under the app's own bar, with
   the article passing behind it.
   Two changes, and the first one is the real fix: the header scrolls away instead of sticking,
   so nothing is permanently covered. The second trims what it takes while it is on screen.
   Deliberately keyed on HEIGHT, not width: it is the short screen that hurts, and a 844px-wide
   landscape phone would otherwise be treated like a tablet. */
@media (max-height: 480px) {
  .site-header { position: static; }
  .nav { padding: 6px 24px; }
  .brand img { height: 30px; }
}

.navlinks { display: flex; gap: 26px; font-size: 14px; font-weight: 500; color: var(--ink-soft); align-items: center; }
.navlinks a:hover, .navlinks a.active { color: var(--accent); }
.nav-cta { flex-shrink: 0; }
.nav-toggle { display: none; background: none; border: 1px solid var(--border); border-radius: 0; padding: 8px 10px; cursor: pointer; }
.nav-toggle span { display: block; width: 20px; height: 2px; background: var(--ink); margin: 4px 0; transition: transform .2s; }

/* ---------- buttons ---------- */
/* A worked flat plane, lit from above (Peter chose the W4 face on 2026-08-04). The 1px inset is
   an EDGE, not a glow underneath -- the same device as the card's lit edge, only stronger (.45
   against the card's .07) because a button sits closer to the light. Do not "unify" the two
   values: the difference is the point.
   Worst contrast against the label is NOT this resting face (8.27 at its bottom stop) but the
   hover one, at 6.82. Both are far clear of 4.5. */
/* font-family and line-height are inherited ON PURPOSE, and they are a fix, not tidiness: a
   <button class="btn"> was 44px tall next to an <a class="btn"> at 50px, because a button takes
   the browser's own font (Arial here) and line-height:normal, while a link takes the page's.
   Peter photographed the result on the product pages. `inherit` equalises them at today's 50px
   without resizing anything else; a fixed line-height would have equalised them at 44px, which
   is a different button on all 42 pages and a design decision, not a bug fix. The form fields
   already spell out font-family: inherit for the same reason -- .btn was the one that missed it. */
.btn {
  display: inline-block;
  font-family: inherit;
  line-height: inherit;
  background: linear-gradient(180deg, var(--accent-lit), var(--accent-mid));
  color: var(--text-on-accent);
  padding: 12px 24px;
  border-radius: 0;
  font-weight: 600;
  font-size: 16px;
  border: 1px solid var(--accent-deep);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
  cursor: pointer;
  transition: background .12s ease;
  text-align: center;
}
/* Hover changes the LIGHT on the face, it does not move the button: depth comes from edges, and
   translateY was movement, not an edge. This rule replaces the old one entirely rather than
   extending it -- the old `box-shadow: none` would put the lit edge out at exactly the moment the
   button is meant to look more lit. */
.btn:hover {
  background: linear-gradient(180deg, var(--accent), var(--accent-sink));
  border-color: var(--accent-deep);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
  transform: none;
}
/* One amber per row: the secondary action is not a second primary, so it carries no accent of
   its own. Peter asked for the W4 ghost face here, word for word (2026-08-04): the same worked
   plane as the primary, one register darker, with the sample's own bright edge.

   The fill is NOT what you see, and that is expected: measured on our surfaces it reads 1.30
   and 1.16 against the page, 1.19 and 1.06 against a card. What announces the button is the
   edge -- and the honest number is that --line-bright lands at 2.69 on the page and 2.46 on a
   card, BELOW the 3.0 a non-text boundary is held to. It is the sample's own value, kept because
   Peter asked for the W4 face literally; the fix, if the number is ever held against us, is a
   brighter edge under the same fill (--field-line reads 4.87 and 4.46), not a lighter fill.

   The lit line is .07 here against the primary's .45 -- the same value the cards use, which is
   what "one register down" means in this language.

   HOVER IS SPELLED OUT IN THE :hover RULES BELOW, not left to source order. .btn:hover and
   .btn.secondary have the same specificity (0,2,0), so today the ghost keeps its own face on
   hover only because it appears later in this file. One reordering and a ghost would turn into
   an amber primary mid-hover. Stating the face at (0,3,0) makes that impossible. */
.btn.secondary {
  background: linear-gradient(180deg, var(--sunk), var(--sunk-deep));
  box-shadow: inset 0 1px 0 rgba(255,255,255,.07);
  color: var(--ink); border-color: var(--line-bright);
}
.btn.secondary:hover, .btn.secondary:focus-visible {
  background: linear-gradient(180deg, var(--sunk), var(--sunk-deep));
  box-shadow: inset 0 1px 0 rgba(255,255,255,.07);
  border-color: var(--ink); color: var(--ink);
}
/* These two modifiers existed because ONE band on a light page was dark, and the accent button
   disappeared into it. The page is dark now, so the exception has no subject left: a button
   inside the CTA band is in the same room as every other button. The markup on six pages still
   names them, so the classes stay, but .on-dark has no rule of its own any more -- it restated
   the .btn face word for word, and the same face written twice comes apart the first time only
   one copy gets edited. It now IS .btn. .ghost-on-dark keeps a rule because it still says
   something: the same sunk face as .secondary, on a card instead of the page. */
.btn.ghost-on-dark {
  background: linear-gradient(180deg, var(--sunk), var(--sunk-deep));
  box-shadow: inset 0 1px 0 rgba(255,255,255,.07);
  color: var(--ink); border-color: var(--line-bright);
}
.btn.ghost-on-dark:hover {
  background: linear-gradient(180deg, var(--sunk), var(--sunk-deep));
  box-shadow: inset 0 1px 0 rgba(255,255,255,.07);
  border-color: var(--ink);
}
.btn.small { padding: 9px 16px; font-size: 14px; border-radius: 0; }
/* .btn.mint is gone: the home page submit was mint, which now means "what you just did
   worked" and nothing else. A primary action wears the action colour. */
.btn:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; }

/* ---------- hero ---------- */
.hero { background: linear-gradient(160deg, #1b1c22 0%, var(--bg) 60%); border-bottom: 1px solid var(--border); overflow: hidden; }
/* Alsó térköz a hero-ra SCOPE-olva, nem a globális `section { padding: 80px 0 }`-on:
   ott minden szekciót átírna. A cél, hogy a következő szekció teteje 1440x900-on
   belógjon a hajtásba ("van még lejjebb" jelzés). Mérve: előtte 25 px látszott
   belőle, utána 101 px. A hero FELSŐ térközéhez nem nyúltam. (Design, 2026-07-30) */
.hero { padding-bottom: 40px; }
.hero-inner { max-width: 1140px; margin: 0 auto; padding: 72px 24px 48px; display: grid; grid-template-columns: 1.05fr 0.95fr; gap: 52px; align-items: center; }
.eyebrow {
  display: inline-flex; align-items: center; gap: 10px;
  font-family: 'IBM Plex Mono', ui-monospace, monospace;
  font-size: 12px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase;
  color: var(--ink-faint); background: none;
  padding: 0; border-radius: 0; margin-bottom: 20px;
}
/* The dot was a 7px circle. Squared it is debris, so it becomes the rule that opens the label:
   12x1, the same hairline the rest of the structure is made of. */
.eyebrow .dot { width: 12px; height: 1px; border-radius: 0; background: var(--line); flex-shrink: 0; }
h1 { font-size: 44px; line-height: 1.12; margin: 0 0 18px; font-weight: 700; letter-spacing: -0.015em; }
h1 .accent { color: var(--accent); }
.hero-sub { font-size: 18px; color: var(--ink-soft); max-width: 500px; margin: 0 0 30px; }
.hero-ctas { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 34px; }
/* The customer quote takes its own line, and the row's vertical gap is separated from the
   horizontal one. Two things this fixes, both older than the quote itself (Design, 2026-08-01):
   the `gap: 26px` shorthand applied to BOTH axes, so a 13.5px line sat in a 26px gutter and
   the second row read as detached; and the customer's voice wore the same olive square as the
   two operational claims, which flattened a quotation into a bullet point.
   The row was already two lines before the quote arrived -- the left column is 546px at every
   width from 1188 to 2560, and the three items need 817px. It never fitted and never will. */
.trust-row { display: flex; gap: 10px 26px; flex-wrap: wrap; font-size: 14px; color: var(--ink-soft); }
.trust-row .trust-quote { flex-basis: 100%; color: var(--ink); }
/* Not a shape at all: the same mono opening quote the reviews already use, so a customer's voice
   is marked the way this site marks quotations rather than the way it marks bullet points. */
.trust-row .trust-quote .chip { display: none; }
.trust-row .trust-quote::before {
  content: "\201C";
  font-family: 'IBM Plex Mono', ui-monospace, monospace;
  font-size: 18px; line-height: 1;
  color: var(--ink-faint);
  margin-right: 6px;
}
.trust-row span { display: flex; align-items: center; gap: 7px; font-weight: 600; }
.trust-row .chip { width: 8px; height: 8px; border-radius: 0; background: var(--ink-faint); flex-shrink: 0; }
.hero-photo { border-radius: 0; overflow: hidden; box-shadow: var(--shadow); border: 1px solid var(--border); }
.hero-photo img { width: 100%; height: 100%; object-fit: cover; aspect-ratio: 4/3; }
/* The hero photograph is a photograph of the work, not a product on a plate: it keeps its
   frame and its crop. The plate treatment is for catalogue images, where the object has to
   be seen whole. */

/* ---------- home page quote form ----------
   Sits under the three problems, not in the hero: the hero was already full on a
   390px screen before a form was added. Centred and narrow so it reads as one
   question rather than a wall of fields. */
/* This block used to describe a dark panel on a white page, with white fields inside it, and
   it kept describing that after the page went dark -- which is worse than no comment, because
   it reads as the reason for what is below it.

   What is true now: the panel is an ordinary card, because the thing that made it a panel was
   the white page it had to stand off. Its fields are dark like every other field, and what
   separates them is their border, not their fill. */
.problems-form {
  max-width: 620px; margin: 46px auto 0; text-align: left;
  background: linear-gradient(180deg, var(--surface-2), var(--surface) 42%);
  border: 1px solid var(--line);
  border-radius: 0; padding: 32px; box-shadow: inset 0 1px 0 rgba(255,255,255,.07);
}
.problems-form h3 { font-size: 27px; margin: 0 0 20px; font-weight: 700; color: var(--ink); text-align: center; }
/* The form inside this block supplies rows, not a surface: the block already is one, and a
   second card face inside it would draw an edge where there is no boundary. */
.problems-form .page-form {
  background: transparent; border: none; box-shadow: none; padding: 0;
}
.problems-form label { color: var(--ink); }                       /* 19.21:1 */
.problems-form .form-note { color: var(--ink-faint); }               /* 7.37:1 on the panel */
.problems-form .form-ok-note { color: var(--mint) !important; } /* 14.17:1 */
.problems-form .form-err-note { color: var(--danger) !important; }/* 9.46:1 */
/* The offset is not cosmetic. A focus ring drawn ON the field competes with the field's own
   fill; drawn just outside it, it sits on the card, where it has the whole surface to be seen
   against. The ring is the accent for the same reason every other one is: focus is where you
   can act. Measured on this theme: 8.91:1 against the card. */
/* .form-row is in the selector deliberately. Without it this is (0,2,1), the same
   weight as `.page-form input:focus` two rules below, and source order hands the
   win to the old faint blue ring. Measured before and after: without .form-row the
   rendered outline was rgba(8,24,248,0.08) 2px, not the mint. */
.problems-form .form-row :is(input, select, textarea):focus-visible {
  outline: 3px solid var(--accent); outline-offset: 2px; border-color: var(--accent);
}
.page-form {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  padding: 26px; box-shadow: var(--shadow); text-align: left;
}
.page-form .form-row { margin-bottom: 14px; }
.page-form label { display: block; font-size: 12px; font-weight: 700; margin-bottom: 6px; }
.page-form input, .page-form select, .page-form textarea {
  width: 100%; padding: 10px 13px; border: 1px solid var(--field-line); border-radius: 0;
  font-size: 14px; font-family: inherit; color: var(--ink); background: var(--field);
}
.page-form input:focus, .page-form select:focus, .page-form textarea:focus {
  outline: 2px solid var(--accent-soft); border-color: var(--accent);
}
.page-form textarea { resize: vertical; }
.page-form .form-note { font-size: 12px; color: var(--ink-faint); margin: 12px 0 0; }
/* name and email share a row on desktop, stack on narrow screens */
.page-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }

/* ---------- the three problems ---------- */
.problems-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
.problem-card {
  display: flex; flex-direction: column; gap: 10px;
  border: 1px solid var(--border); border-radius: var(--radius); padding: 26px 24px; background: var(--surface);
  transition: border-color .15s ease, transform .15s ease, box-shadow .15s ease;
}
.problem-card:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow); }
.problem-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-color: var(--accent); }
.problem-card h3 { font-size: 18px; margin: 0; font-weight: 700; line-height: 1.35; }
.problem-card p { font-size: 14px; color: var(--ink-soft); margin: 0; flex: 1; }
.problem-card:hover .card-cue { text-decoration: underline; }

/* ---------- sections ---------- */
section { padding: 80px 0; }
section.soft { background: var(--bg-soft); }
.section-head { max-width: 640px; margin: 0 auto 46px; text-align: center; }
.section-head .eyebrow { margin-bottom: 14px; }
.section-head h2 { font-size: 31px; margin: 0 0 12px; font-weight: 700; letter-spacing: -0.01em; }
.section-head p { color: var(--ink-soft); font-size: 16px; margin: 0; }
.section-foot { text-align: center; margin-top: 40px; }

/* ---------- page hero (subpages) ---------- */
.page-hero { background: linear-gradient(160deg, #1b1c22 0%, var(--bg) 70%); border-bottom: 1px solid var(--border); padding: 52px 0 46px; }
.page-hero h1 { font-size: 36px; margin-bottom: 10px; }
.page-hero p { color: var(--ink-soft); font-size: 16px; max-width: 640px; margin: 0; }

/* ---------- home page service tiles ----------
   Full-bleed photo tiles, home page only. Everything is scoped to
   `.services-grid .service-card` on purpose: the SERVICES page renders the same
   service data through `.service-row` with the small 160px icons, and must not
   move with this. That is also why the old icon files stay in the repo.

   The gradient lives on .tile-text, not on the card. Attached to the text box it
   always sits exactly behind the words, however long they run; attached to the card
   with fixed percentages, a longer description would slide out of the protected
   band. Measured against the worst case, a white image under white text (the design
   tile really does have white paper there): in the 0.94-0.88 alpha band the heading
   is 17.7:1 and the description 10.8:1. At the 0.62 end of the fade the description
   would be 4.1:1 and fail AA, which is what the 64px top padding keeps it out of.
   If the text ever grows, the gradient stops have to grow with it. */
/* Grid overlay: one cell, both children in it. The row takes the height of the
   TALLER child, so the picture's 4:3 acts as a floor and a long description makes
   the card grow instead of being cut off.

   The two obvious versions do not work, and both were measured before this one:
   aspect-ratio on the card gives a definite height that in-flow text cannot push
   past (at 561px the text was 258px inside a 184px card, the top two thirds simply
   gone), and adding min-height: max-content did not override it either. */
.services-grid .service-card {
  position: relative; display: grid; padding: 0; border: none; overflow: hidden;
  border-radius: var(--radius); background: var(--surface);
}
/* Portrait, because Peter's photographs are 2:3 and he left the lower third empty for
   this text. A 4:3 crop threw away half the picture and three quarters of that band. */
.services-grid .service-card > picture { grid-area: 1 / 1; display: block; aspect-ratio: 2 / 3; }
.services-grid .service-card .tile-text { grid-area: 1 / 1; align-self: end; }
.services-grid .service-card img { width: 100%; height: 100%; object-fit: cover; }
/* The text sits in normal flow, not absolutely positioned. Absolute text gives the
   card no height, so with a fixed aspect-ratio and overflow:hidden anything taller
   than the card loses its TOP silently: no overflow, no error, just a missing first
   line. In flow the text contributes height and aspect-ratio acts as a floor rather
   than a ceiling, so a long description makes the card grow instead of cutting. */
.services-grid .service-card .tile-text {
  position: relative; padding: 64px 20px 20px; z-index: 1;
  /* Lighter than the old 0.94/0.88, deliberately: more of Peter's photograph shows
     through, and it still measures safe. Design sampled the brightest pixel in each
     text band and worked out the alpha white needs for 4.5:1 -- worst case 0.49, on
     the CAD desk. Verified independently, same numbers, including the sanity check
     that pure white would need 0.56. Text sits in the 0-55% band where alpha is at
     least 0.62, so the tightest real case is the description at 4.99:1. If the text
     ever grows past that band, these stops have to grow with it. */
  /* The 0.62 stop is anchored to the padding, not to a percentage. Two earlier versions
     put it at 55% and then at 75%, and both were calibrated to one particular text
     length: with 64px of top padding and a four-line description the HEADING reaches
     66-70% of the gradient, where the 55% stop had already faded to 0.41, below the 0.49
     the brightest text band needs. That headline measured 3.87:1 and failed AA while the
     description below it passed, because the description sits lower in the fade.
     Calculating from the design intent missed it; only the rendered pixels showed it.

     Since the gradient box IS the padded box, calc(100% - 64px) lands exactly on the top
     edge of the text, whatever the text does. Every glyph therefore sits at alpha >= 0.62
     and the whole fade happens inside the 64px padding, where there are no letters. A
     sixth line of description moves the text down within the flat band instead of up into
     the fade, so it cannot reopen the bug. Change the padding and the gradient follows.

     The old 75% was not equivalent, it was accidentally a shade safer: .tile-text renders
     188-212px tall, not the 256px that would put 75% on the padding edge, so that stop
     landed 11-17px ABOVE the text and gave the top line ~0.628 instead of 0.62. Measured
     cost of anchoring it properly: the worst case moves 6.31:1 -> 6.19:1, still 1.38x the
     4.5:1 minimum, and the whole set stays in 6.19-15.09:1. That accident does not survive
     a longer description: at a 300px box the old stop would have dropped the top line to
     ~0.53, which is how this bug worked the first two times. */
  background: linear-gradient(to top,
      rgba(8,9,20,0.74) 0, rgba(8,9,20,0.62) calc(100% - 64px), rgba(8,9,20,0) 100%);
}
.services-grid .service-card h3 { color: var(--ink); margin: 0 0 6px; }
/* The arrow is absolutely positioned, so text flows underneath it unless the
   paragraph reserves the space. Measured at 390px: without this, the last line of
   two of the five tiles ran into the glyph. It only shows at narrow widths, where
   the description wraps to fill the line. */
.services-grid .service-card p { color: var(--ink); margin: 0; padding-right: 30px; }
/* The affordance is a glyph, not a label: Peter found "Learn more" redundant, but a
   hover zoom says nothing on a touch screen, where there is no hover. */
.services-grid .service-card .tile-text::after {
  content: "\2192"; position: absolute; right: 20px; bottom: 20px;
  color: var(--ink); opacity: .85; font-size: 18px;
}
.services-grid .service-card img { transition: transform .35s ease; }
.services-grid .service-card:hover img { transform: scale(1.05); }

/* ---------- service cards ---------- */
.services-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
/* The bottom row of two. This used to be an inline style on the element, which beat
   every media query below: at 390px the two tiles stayed side by side at 160px wide
   while the other three were 342px, and their text overflowed the card by 283px and
   was silently clipped. Inline styles are unreachable from a breakpoint; a class is
   not. */
.services-grid-pair { grid-template-columns: repeat(2, 1fr); max-width: 760px; margin: 22px auto 0; }
.service-card {
  border: 1px solid var(--border); border-radius: var(--radius); padding: 28px 24px;
  background: var(--surface); display: flex; flex-direction: column; gap: 13px;
  transition: border-color .15s ease, transform .15s ease, box-shadow .15s ease;
}
section.soft .service-card { background: var(--surface); }
.service-card:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow); }
.service-icon { width: 46px; height: 46px; border-radius: 0; background: var(--accent); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.service-icon svg { width: 24px; height: 24px; stroke: var(--text-on-accent); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.service-icon-img {
  width: 88px; height: 88px; border-radius: 0; overflow: hidden; flex-shrink: 0;
  background: linear-gradient(150deg, var(--bg-soft), #1b1c22);
  border: 1px solid var(--border);
  display: flex; align-items: center; justify-content: center;
}
/* The service icons are photographs (Design, 2026-07-27), not flat pictograms.
   mix-blend-mode: multiply was removed with them: it lets light pixels through to
   the tile's gradient, which tints a photo and washes its light background away. */
/* <picture> is inline by default, so the img's height:100% would resolve against a
   shrink-to-fit box instead of the tile. Make the wrapper fill the tile itself. */
.service-icon-img picture { display: block; width: 100%; height: 100%; }
.service-icon-img img { width: 100%; height: 100%; object-fit: cover; }
.service-card h3 { font-size: 18px; margin: 0; font-weight: 700; }
.service-card p { font-size: 14px; color: var(--ink-soft); margin: 0; flex: 1; }
.service-card .card-cta { margin-top: 4px; }
/* The whole card is a link now. "Learn more" is a cue, not a link of its own:
   a link inside a link is invalid HTML. It still has to pick up the hover state,
   which it only can from the card, not from itself. */
.card-cue {
  font-size: 14px; font-weight: 700; color: var(--accent);
  display: inline-flex; align-items: center; gap: 6px; margin-top: 4px;
}
.service-card:hover .card-cue { text-decoration: underline; }
/* Keyboard users get the same signal as the mouse, not just the browser outline. */
.service-card:focus-visible, .portfolio-card:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 3px; border-color: var(--accent);
}
.quote-link { font-size: 14px; font-weight: 700; color: var(--accent); display: inline-flex; align-items: center; gap: 6px; }
.quote-link:hover { text-decoration: underline; }

/* service detail rows (services page) */
/* Service rows carry the large photos (Design, 2026-07-27, at Peter's request).
   The size lives on .service-row .service-icon-img, never on the base
   .service-icon-img rule: the same class renders the home page service cards,
   and those stay at 88px. */
/* Two columns, not three: the per-service quote button is gone (Peter, 2026-07-29), so
   the third `auto` column would reserve a gap for nothing and leave the copy narrower
   than it has to be. */
/* 520px photo, not 160 and not 360 (Design, 2026-07-29, decided ON THE RENDERS after the
   numbers had pointed at 360). Two things the measurements did not show:

   The horizontal slack is the smaller gap. At 360 the copy runs far past the bottom of the
   photo, leaving ~580px empty UNDER it in the left column and an L-shaped row; at 520 that
   drops to ~260 and the two columns balance.

   And at 360 the CAD screen in the photo is not legible. At 520 the wireframe, the dimension
   lines and the "Aluminum Alloy 6061" label read. On a row selling 3D DESIGN the photo is the
   evidence, not the mood.

   The text does not lose: it stays capped at 520px and 75 characters either way. What changes
   is only whether the left half is empty. */
.service-row { display: grid; grid-template-columns: 520px 1fr; gap: 24px; align-items: start; padding: 32px 0; border-bottom: 1px solid var(--border); }
.service-row .service-icon-img { width: 520px; height: 520px; border-radius: 0; }
/* The measure, capped where it is READABLE rather than where the column happens to end.
   Measured across all ten paragraphs at 1360px: 520px gives a 75-character worst line, which
   is the top of the comfortable 60-75 band. The obvious-looking "75ch" does NOT: `ch` is the
   width of the "0" glyph, which in this face is 1.37x narrower than the average character,
   so 75ch renders 750px and 103 characters. The unit sounds like the thing it is not. */
.service-row > div:nth-child(2) { max-width: 520px; }
.service-row:last-child { border-bottom: none; }
.service-row h3 { margin: 0 0 8px; font-size: 21px; }
.service-row p { margin: 0 0 10px; color: var(--ink-soft); font-size: 16px; }
.service-row ul { margin: 8px 0 0; padding-left: 20px; color: var(--ink-soft); font-size: 14px; }
.service-row li { margin-bottom: 4px; }
/* The two lists answer different questions, so they need to look like two lists and
   not one long one. The heading is what separates them; without it the reader just
   sees more bullets. */
.service-row h4 {
  font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
  color: var(--ink); margin: 18px 0 2px;
}
.service-row h4:first-of-type { margin-top: 14px; }
.service-row li strong { color: var(--ink); font-weight: 600; }
.service-row .service-closing { margin-top: 14px; font-style: italic; color: var(--ink-soft); }

/* ---------- product cards ---------- */
.products-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
/* The whole card is the click target (Peter, 2026-08-02). NOT by wrapping the card in an <a>:
   that nests the interactive bits inside a link, which is invalid HTML and takes the click away
   from them. The title link stretches over the card with a pseudo-element instead, so the card
   keeps exactly one link and one tab stop. position: relative is what the stretch measures
   against -- without it the ::after escapes to the nearest positioned ancestor. */
.product-card, .blog-card, .why-card, .review, .problem-card, .service-card, .form-card, .page-form, .quote-item {
  background: linear-gradient(180deg, var(--surface-2), var(--surface) 42%);
  border-color: var(--line);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.07);
}
.product-card {
  position: relative;
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); overflow: hidden;
  display: flex; flex-direction: column;
  transition: border-color .15s ease, transform .15s ease, box-shadow .15s ease;
}
.product-card h3 a::after { content: ""; position: absolute; inset: 0; z-index: 1; }
/* The card is a link surface now, so the whole thing has to say so, not just the title. */
.product-card { cursor: pointer; }
.product-card:hover h3 a, .product-card:focus-within h3 a { text-decoration: underline; }
/* Anything that must stay above the stretched link goes here. Nothing does today: the in-card
   chevrons were removed on 2026-07-28 and the CSS below is dead. If an interactive element is
   ever added back to this card, it needs position: relative and a z-index above 1, or the
   stretch will swallow its clicks. */
.product-card:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow); }
.product-card .thumb {
  aspect-ratio: 1/1; background: var(--plate); overflow: hidden;
  margin: 12px; border: 1px solid var(--line);
}
.product-card .thumb img { width: 100%; height: 100%; object-fit: contain; }
.product-card .body { padding: 18px 18px 20px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.product-card h3 { font-size: 16px; margin: 0; font-weight: 700; line-height: 1.35; }
.product-card .desc { font-size: 14px; color: var(--ink-soft); margin: 0; flex: 1; }
.product-card .price-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 6px; }
.product-card .price { font-size: 18px; font-weight: 700; color: var(--ink); }
.product-card .price .cur { font-size: 12px; font-weight: 600; color: var(--ink-faint); margin-left: 3px; }
.product-card .from { font-size: 12px; color: var(--ink-faint); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }

/* filters */
.filter-bar { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; margin-bottom: 38px; }
/* font-family: inherit for the same reason as on .btn -- a <button> otherwise stands in the
   browser's own font. These six had no link beside them to be measured against, so nothing gave
   it away; they were simply set in Arial on the products page. Measured 2026-08-04. */
.filter-btn {
  border: 1px solid var(--line); background: transparent; color: var(--ink-soft);
  font-family: inherit;
  padding: 8px 16px; min-height: 44px; border-radius: 0; font-size: 14px; font-weight: 600; cursor: pointer;
  transition: all .12s ease;
}
.filter-btn:hover { border-color: var(--accent); color: var(--accent); }
.filter-btn.active { background: var(--accent); border-color: var(--accent); color: var(--text-on-accent); }

/* ---------- portfolio ---------- */
.portfolio-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
/* display:block because the card is an <a> now, and an inline box would collapse
   around the absolutely positioned caption instead of holding the 4/3 ratio. */
.portfolio-card { display: block; position: relative; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border); aspect-ratio: 4/3; }
.portfolio-card img { width: 100%; height: 100%; object-fit: cover; transition: transform .25s ease; }
.portfolio-card:hover img { transform: scale(1.04); }
.portfolio-card .cap {
  position: absolute; left: 0; right: 0; bottom: 0;
  background: linear-gradient(transparent, rgba(10, 12, 30, 0.82));
  color: var(--ink); padding: 34px 16px 12px; font-size: 14px; font-weight: 600;
}
.portfolio-card .cap small { display: block; font-weight: 500; opacity: .8; font-size: 12px; }

/* ---------- process ---------- */
.process-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; position: relative; }
.process-step { text-align: center; padding: 0 16px; position: relative; }
.process-step .num { width: 42px; height: 42px; border-radius: 0; background: transparent; color: var(--ink); border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; font-weight: 600; margin: 0 auto 16px; }
.process-step h4 { font-size: 16px; margin: 0 0 8px; }
.process-step p { font-size: 14px; color: var(--ink-soft); margin: 0; }
.process-grid .process-step:not(:last-child)::after {
  content: ""; position: absolute; top: 21px; left: calc(50% + 38px); width: calc(100% - 76px);
  height: 2px; background: repeating-linear-gradient(90deg, var(--border) 0 6px, transparent 6px 12px);
}

/* ---------- why us ---------- */
.why-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
.why-card { padding: 24px; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); }
.why-card .why-ico { width: 40px; height: 40px; border-radius: 0; background: var(--surface-2); border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; margin-bottom: 14px; }
.why-card .why-ico svg { width: 22px; height: 22px; stroke: var(--ink); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.why-card h3 { font-size: 16px; margin: 0 0 8px; }
.why-card p { font-size: 14px; color: var(--ink-soft); margin: 0; }
/* ---------- customer reviews ----------
   Quoted verbatim, so the styling has to carry the credibility that a tidied-up sentence
   would have carried instead: real name, real platform, no star graphics we did not earn.
   No count and no average anywhere -- that is Peter's rule while the total is under ten. */
.reviews-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; align-items: start; }
.review { margin: 0; padding: 26px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
.review blockquote { margin: 0 0 16px; font-size: 16px; line-height: 1.65; color: var(--ink); }
.review blockquote::before { content: "\201C"; display: block; font-family: 'IBM Plex Mono', monospace; font-size: 31px; line-height: 1; color: var(--ink-faint); margin-bottom: 6px; }
.review figcaption { font-size: 14px; font-weight: 700; color: var(--ink); display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
.review figcaption span { font-weight: 500; color: var(--ink-faint); font-size: 12px; }
/* The services-page one is a single wider card: it is longer, business-to-business copy, and
   a third of a row would set it in a column too narrow to read. */
.review-wide { max-width: 760px; margin: 0 auto; }
@media (max-width: 960px) { .reviews-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .reviews-grid { grid-template-columns: 1fr; } }

/* One promise, on one line, under the three why-cards. Deliberately not a fourth card: the
   grid is three wide on desktop and a fourth would orphan itself on a second row, and a
   promise reads differently from a feature -- it should not look like one of four things we
   happen to be good at.

   The wording is exact and deliberately narrow (Peter, 2026-07-30): it covers a part that
   misses the AGREED SPEC, and nothing else. Not a time-limited warranty, not breakage in
   use. Broadening it is Peter's decision, not a copy edit.

   Halkitva (Design, 2026-07-30): a tomor --mint teljes szelessegben eross foltot adott, mint
   a felette levo harom kartya ikon-negyzetei egyutt -- es erosebbet, mint az alatta kovetkezo
   kek CTA-panel, ami a fo cselekvesre hivas. Ezen az oldalon a menta IKON-jelolo szin, nem
   felulet-szin: 48 px-es foltkent hangsuly, 1500 px-en zaj. A garancia ezen felul nyugtatas,
   nem figyelmeztetes -- a tomor szinsav riasztas-savkent olvasodott.
   A minta nem uj: az .eyebrow ugyanezt csinalja kekkel (--accent-soft hatter + telitett szoveg).
   A szoveghez es a szerkezethez nem nyultam, csak a szinekhez. */
.promise-strip { display: flex; align-items: center; gap: 14px; margin-top: 22px; padding: 18px 24px; border-radius: var(--radius); background: var(--surface-2); border: 1px solid var(--line); }
.promise-strip .promise-ico { flex: 0 0 auto; display: flex; }
.promise-strip .promise-ico svg { width: 22px; height: 22px; stroke: var(--ink-soft); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.promise-strip p { margin: 0; font-size: 16px; font-weight: 600; color: var(--ink); }

/* ---------- blog ---------- */
.blog-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
.blog-card { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--surface); display: flex; flex-direction: column; transition: border-color .15s, transform .15s, box-shadow .15s; }
.blog-card:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow); }
.blog-card .thumb {
  aspect-ratio: 1/1; background: var(--plate); overflow: hidden;
  margin: 12px; border: 1px solid var(--line);
}
.blog-card .thumb img { width: 100%; height: 100%; object-fit: contain; }
.blog-card .body { padding: 20px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.blog-card .meta { font-size: 12px; color: var(--ink-faint); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.blog-card h3 { font-size: 16px; margin: 0; line-height: 1.35; }
.blog-card p { font-size: 14px; color: var(--ink-soft); margin: 0; flex: 1; }

/* blog article */
.article { max-width: 760px; margin: 0 auto; padding: 56px 24px 72px; }
.article .meta { font-size: 12px; color: var(--ink-faint); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 12px; }
.article h1 { font-size: 31px; margin: 0 0 22px; }
.article .lead { font-size: 18px; color: var(--ink-soft); margin-bottom: 28px; }
.article h2 { font-size: 21px; margin: 36px 0 12px; }
.article p { font-size: 16px; color: var(--ink-soft); margin: 0 0 16px; }
.article img { width: 100%; height: auto; border-radius: var(--radius); margin: 26px 0; border: 1px solid var(--border); }
/* process step figure: image + caption walking through the actual workflow */
.article figure.step { margin: 26px 0; }
.article figure.step img { margin: 0 0 10px; }
.article figure.step figcaption {
  font-size: 14px; color: var(--ink-soft); font-weight: 600;
  border-left: 1px solid var(--line); padding-left: 12px;
}
/* Links inside article prose. They were invisible until 2026-08-19: `a` is color: inherit and
   text-decoration: none site-wide, which is right for cards and nav, where the whole box is the
   link, and wrong inside a paragraph, where the sentence is the only thing telling you a link is
   there. Measured on the ClueMart article before this rule: the in-body link rendered
   rgb(184,187,196) with no underline, the same as the text either side of it.

   An underline rather than the accent colour: a paragraph with orange words in it reads as a
   list of interruptions, and orange prose would take the closing button's precedence away. The
   colour is kept for the hover, which is where the change of state belongs. Design, 2026-08-19.

   Three selectors, and two of them match nothing today. List items and captions carry no links
   in any of the twelve articles, which is exactly why they are here: the rule is written now,
   while the set is empty and the omission is free, rather than on the day the first article with
   a linked bullet ships looking fine to everyone who already knows where its links are.

   Two exclusions, same reasoning. .card-cue and .btn carry their own colour and weight, and this
   rule would overwrite both and underline them. A link wrapped around an image gets no underline
   either: it would draw an orange line under the picture, reading as part of it.

   The image exclusion repeats :not(.card-cue):not(.btn), and that is not tidiness. Written the
   obvious way, `.article p a:has(img)`, it LOSES: :has() takes the specificity of its argument,
   so that selector scores (0,1,3) against the (0,3,2) of the rule above, and the underline stays.
   Measured, not reasoned: the case was built in the DOM and the computed style read back, and it
   came out underlined until these two got the same weight. Nothing on the site matches it today,
   so nothing would have shown it. */
.article p a:not(.card-cue):not(.btn),
.article li a:not(.card-cue):not(.btn),
.article figcaption a:not(.card-cue):not(.btn) {
  color: inherit; text-decoration: underline; text-decoration-color: var(--accent);
  text-underline-offset: 3px; text-decoration-thickness: 1px;
}
.article p a:not(.card-cue):not(.btn):hover,
.article li a:not(.card-cue):not(.btn):hover,
.article figcaption a:not(.card-cue):not(.btn):hover { color: var(--accent); }
.article p a:not(.card-cue):not(.btn):has(img),
.article li a:not(.card-cue):not(.btn):has(img),
.article figcaption a:not(.card-cue):not(.btn):has(img) { text-decoration: none; }

/* Related block at the foot of an article. Quieter than the quote CTA that follows it: a rule
   and a small heading, not a second panel. Two panels in a row read as two calls to action, and
   only one of them is one. The links themselves are plain <li> links inside .article, so they
   pick up the prose underline rule above -- they are the first list links on the site, which is
   what that selector was written for when nothing matched it. */
.article .article-related { margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--border); }
.article .article-related h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--ink-faint); font-weight: 600; }
.article .article-related ul { margin: 0; padding-left: 20px; }
.article .article-related li { margin-bottom: 6px; font-size: 15px; }

.article .tm-notice { font-size: 12px; color: var(--ink-faint); line-height: 1.5; margin-top: 34px; padding-top: 14px; border-top: 1px solid var(--border); }
.article .article-cta { background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 26px; margin-top: 40px; text-align: center; }
.article .article-cta h3 { margin: 0 0 8px; font-size: 18px; }
.article .article-cta p { margin: 0 0 16px; font-size: 14px; }

/* ---------- CTA band ---------- */
/* Not an amber block: a band the width of the page is the biggest thing on it, and you cannot
   click a band. The accent stays on the button inside, where the action is. It also put
   --ink on amber at 1.63:1. */
.cta-band { background: var(--surface); border: 1px solid var(--line); color: var(--ink); border-radius: 0; padding: 52px 48px; display: flex; align-items: center; justify-content: space-between; gap: 32px; flex-wrap: wrap; }
.cta-band h2 { font-size: 27px; margin: 0 0 10px; font-weight: 700; }
.cta-band p { margin: 0; opacity: .92; font-size: 16px; max-width: 480px; }
.cta-band-ctas { display: flex; gap: 14px; flex-wrap: wrap; }

/* ---------- forms / contact ---------- */
.contact-grid { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 48px; align-items: start; }
.form-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 30px; box-shadow: var(--shadow); }
.form-row { margin-bottom: 16px; }
.form-row label { display: block; font-size: 14px; font-weight: 700; margin-bottom: 6px; }
.form-row input, .form-row select, .form-row textarea {
  width: 100%; padding: 11px 13px; border: 1px solid var(--field-line); border-radius: 0;
  font-size: 14px; font-family: inherit; color: var(--ink); background: var(--field);
}
.form-row input:focus, .form-row select:focus, .form-row textarea:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
/* The white contact card: the field's own background is the same #fff as the card, so the
   1px border is the ONLY thing that draws the field. At --border it was 1.22:1 against the
   card, below the 3:1 WCAG 1.4.11 needs for a UI boundary -- Peter reported the fields as
   invisible. #7C8398 is 3.78:1. Scoped to .form-card so the dark homepage panel, where the
   white field background already carries 19.21:1, is untouched. */
.form-card .form-row input, .form-card .form-row select, .form-card .form-row textarea { border-color: var(--field-line); }
/* The old focus ring was rgba(8,24,248,0.08) = 1.16:1, decoration rather than an indicator.
   The blue border alone did the work at 8.41:1. Now the ring is the blue itself, offset 2px
   so it reads outside the border instead of merging into it. */
.form-card .form-row input:focus, .form-card .form-row select:focus, .form-card .form-row textarea:focus {
  outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent);
}
/* There was no per-field error state at all: an invalid field kept the same border as a valid
   one, and the only signal was the form-level note under the button. :user-invalid fires only
   after the visitor has interacted, so nothing is red on load and no JS change is needed. */
.form-card .form-row :user-invalid { border-color: var(--danger); }
.form-card .form-row :user-invalid:focus { outline-color: var(--danger); }
/* The one message that confirms the request was sent, so it has to be readable and it has to
   read as success rather than as another line of grey. --mint is the only survivor of the old
   mint family and this is one of its two remaining jobs. !important because the colour is inline
   in the markup. 11.42:1 on a card. */
.form-card .form-ok-note { color: var(--mint) !important; }   /* 11.42 on the card */
/* 12.5px is small text, so it needs 4.5 whatever the theme is. --ink-faint carries it at 4.54
   on the lightest card surface -- which is why that token could not stay at #6c6f79 (3.09). */
.form-card .form-note { color: var(--ink-faint); }
/* A placeholder is text a visitor has to read before typing over it, so the small-text threshold
   applies to it too. Same token as the note above, same 4.54, and site-wide rather than scoped:
   there is one placeholder colour and it should not depend on which card the field sits in. */
input::placeholder, textarea::placeholder { color: var(--ink-faint); opacity: 1; }
.form-note { font-size: 12px; color: var(--ink-faint); margin-top: 12px; }
.contact-aside h3 { font-size: 16px; margin: 0 0 6px; }
.contact-aside p { color: var(--ink-soft); font-size: 14px; margin: 0 0 22px; }
.contact-aside a { color: var(--accent); font-weight: 600; }

/* ---------- order modal ---------- */

/* ---------- product photo count ---------- */
.product-card .thumb { position: relative; display: block; }
.photo-count {
  position: absolute; right: 10px; bottom: 10px;
  background: rgba(10, 12, 30, 0.72); color: var(--ink);
  font-size: 12px; font-weight: 700; letter-spacing: .03em;
  padding: 4px 10px; border-radius: 0; pointer-events: none;
}
/* The gallery lightbox was removed on 2026-08-02. Measured rather than assumed: it did not
   magnify -- it loaded the same 900px source as the page, and on one product it rendered
   NARROWER than the image already on screen (0.84x). The one thing it alone did was show
   the photo uncropped, and .pd-main below does that now. Its styles go with it in the same
   change, because style for a feature that is gone reads as a claim that it is still there. */

/* The in-card gallery (chevrons + dots on the list thumbnail) was REMOVED on 2026-07-28 and is
   not coming back: it put buttons inside the thumbnail's link, which is invalid HTML and takes
   the click away from the link. Every photo lives on the product's own page instead.
   Its styles outlived it here until 2026-08-02, and they did real damage on the way out -- a
   reviewer read them, concluded the feature existed, and warned about breaking it. Style for
   something that does not exist is not harmless leftovers; it is a claim that it is there.
   The cursor now comes from .product-card, which is one click target end to end. */

/* ---------- product detail page ---------- */
.crumbs { font-size: 12px; color: var(--ink-faint); margin-bottom: 26px; display: flex; gap: 8px; flex-wrap: wrap; }
.crumbs a { color: var(--ink-soft); font-weight: 600; }
.crumbs a:hover { color: var(--accent); }
.product-detail { padding: 40px 0 64px; }
.pd-grid { display: grid; grid-template-columns: 1.05fr 0.95fr; gap: 48px; align-items: start; }
/* A grid item's min-width is auto, so it grows to its widest content instead of clipping it.
   Without this the scrolling thumbnail strip below made the gallery column 916px wide inside a
   390px phone, and the whole page scrolled sideways -- a worse fault than the wrapping it
   replaced. Measured, not guessed: the first fix looked right in one row and was broken. */
.pd-gallery { min-width: 0; }
/* Square and uncropped (Design measured it, 2026-08-02). The 4:3 box with object-fit: cover was
   throwing away 26.7% of the average photo, and 17 of the 23 products shoot portrait or square.
   On the hydroponic planter stand the crop took the FOOT off -- the part being sold. A square
   box with contain shows the whole frame: average visible image area 165 153 -> 244 646 px2,
   and nothing gets smaller, because the widest photo was never the one being cut.
   --bg-soft stays: it is what fills the letterbox band beside a portrait photo.
   No cursor and no focus ring any more -- the image stopped being a control when the lightbox
   went, and a thing that looks clickable and is not is worse than a plain picture. */
.pd-main {
  position: relative; display: block; width: 100%; aspect-ratio: 1/1; cursor: default;
  background: var(--plate); border: 1px solid var(--line); padding: 12px;
  background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
}
.pd-main img { width: 100%; height: 100%; object-fit: contain; }
/* Step arrows on the main image. Deliberately NOT hover-gated: the lightbox arrows were
   opacity 0 until hover and display:none under (hover: none), so on a phone they did not exist
   at all -- one of the reasons the popup was only in the way there. These are visible from the
   start on every device, which is the whole point of putting them on the page. */
.pd-arrow {
  position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
  width: 44px; height: 44px; border-radius: 0; border: 1px solid var(--line); cursor: pointer;
  background: rgba(28,29,33,0.92); color: var(--ink); font-size: 21px; line-height: 1;
  display: flex; align-items: center; justify-content: center; padding-bottom: 3px;
  box-shadow: none;
  transition: background .12s ease;
}
/* An edge, not a fill: the arrow sits ON a photograph, so the amber says "press here"
   without putting a block of accent over the picture. */
.pd-arrow:hover, .pd-arrow:focus-visible { border-color: var(--accent); color: var(--accent); }
.pd-arrow:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pd-arrow.prev { left: 10px; }
.pd-arrow.next { right: 10px; }
/* Scrollable, not wrapped (Peter, 2026-08-02). Measured on the delivered copy first, because
   three thumbnail strips exist and only this one was the problem: the lightbox filmstrip
   already scrolls, and the list cards have no strip at all. This one wrapped -- the product
   with 11 photos ran to two rows at 1360px and THREE at 390px, 187px of thumbnails pushing
   the price and the buy button below the fold on a phone. One row that scrolls instead. */
.pd-thumbs {
  display: flex; gap: 8px; margin-top: 10px; overflow-x: auto;
  padding-bottom: 4px; scrollbar-width: thin; -webkit-overflow-scrolling: touch;
}
.pd-thumb {
  flex: 0 0 auto;
  width: 60px; height: 60px; padding: 0; overflow: hidden; cursor: pointer;
  border: 1px solid var(--line); border-radius: 0; background: var(--plate);
  transition: border-color .12s ease, opacity .12s ease; opacity: .8;
}
.pd-thumb img { width: 100%; height: 100%; object-fit: contain; display: block; background: var(--plate); }
.pd-thumb:hover, .pd-thumb:focus-visible { opacity: 1; border-color: var(--accent); }
.pd-thumb.on { opacity: 1; border-color: var(--accent); }
.pd-info h1 { font-size: 31px; margin: 12px 0 14px; }
.pd-info .eyebrow { margin-bottom: 0; }
.pd-lead { font-size: 16px; color: var(--ink-soft); margin: 0 0 20px; }
.pd-price { font-size: 31px; font-weight: 700; margin-bottom: 20px; }
.pd-price .cur { font-size: 12px; font-weight: 600; color: var(--ink-faint); margin-left: 4px; }
.pd-price .from { font-size: 12px; color: var(--ink-faint); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-right: 4px; }
/* The row is two equal 50px buttons now, so align-items has nothing left to equalise -- the
   payment column that made this necessary went with the online checkout on 2026-08-02.
   (52px until 2026-08-04: the W4 face took the border from 2px to 1px. And they were briefly
   NOT equal that night -- 44 against 50 -- because .btn inherited neither font nor line-height,
   so a <button> used the browser's Arial. Peter photographed it. The fix is on .btn itself.)

   The declaration STAYS anyway, and not out of habit: with the default (stretch) a single
   taller sibling silently pulls the other one to its height, and that is exactly the bug Peter
   reported -- an empty blue outline the height of a paragraph next to the buy buttons. Keeping
   a non-stretch value means the row cannot regress into it if something uneven is added back.
   flex-END rather than flex-start is Design's call from 2026-08-02, made when the heights
   differed; with equal heights the two render identically, so it is left as chosen.
   390px never took part: the row wraps there, one item per line, and align-items does nothing. */
.pd-ctas { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 14px; }
.pd-pay-note { font-size: 12px; color: var(--ink-faint); margin: 0 0 22px; }
.pd-facts { list-style: none; margin: 0; padding: 20px 0 0; border-top: 1px solid var(--border); display: grid; gap: 10px; }
.pd-facts li { font-size: 14px; color: var(--ink-soft); padding-left: 24px; position: relative; }
.pd-facts li::before {
  content: ""; position: absolute; left: 0; top: 6px; width: 12px; height: 7px;
  border-left: 2px solid var(--ink-faint); border-bottom: 2px solid var(--ink-faint); transform: rotate(-45deg);
}
.pd-body { max-width: 760px; margin: 56px auto 0; padding-top: 40px; border-top: 1px solid var(--border); }
.pd-body p { font-size: 16px; color: var(--ink-soft); margin: 0 0 16px; }
.pd-body h3 { font-size: 18px; margin: 30px 0 12px; }
.pd-body ul, .pd-body ol { margin: 0 0 18px; padding-left: 22px; color: var(--ink-soft); font-size: 16px; }
.pd-body li { margin-bottom: 7px; }
/* A link in here would otherwise be invisible: the base rule is `a { color: inherit;
   text-decoration: none }`, so it renders as plain body text that happens to be clickable. Same
   values as the article prose rule, same two exclusions, so the two read as one style. Scoped to
   .pd-body rather than to the one sentence that needs it today, because the Shopify descriptions
   render here too and the day one of them arrives with a link, it would be invisible in exactly
   the same way. */
.pd-body a:not(.card-cue):not(.btn) {
  color: inherit; text-decoration: underline; text-decoration-color: var(--accent);
  text-underline-offset: 3px; text-decoration-thickness: 1px;
}
.pd-body a:not(.card-cue):not(.btn):hover { color: var(--accent); }
/* Scoped .pd-body .pd-story, not a bare .pd-story: this is a <p> inside .pd-body, and
   `.pd-body p` at (0,1,1) sets font-size, colour and margin. A bare class is (0,1,0) and loses
   all three. Read back from the computed style, not reasoned about. */
.pd-body .pd-story { margin: 22px 0 0; padding-top: 16px; border-top: 1px solid var(--border); font-size: 15px; color: var(--ink-faint); }

/* ---------- footer ---------- */
.site-footer { border-top: 1px solid var(--border); padding: 52px 0 30px; background: var(--bg-soft); }
.footer-grid { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 36px; margin-bottom: 34px; }
.footer-brand img { height: 34px; margin-bottom: 12px; }
.site-footer .col h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--ink-faint); margin: 0 0 12px; }
.site-footer .col a, .site-footer .col p { display: block; font-size: 14px; color: var(--ink-soft); margin: 0 0 8px; }
.site-footer .col a:hover { color: var(--accent); }

/* Touch targets, and only where there is a thumb. A 14px link in a block is 22px tall, which
   is half of what a finger needs, and the footer had twelve of them. Padding rather than a
   bigger type size, so nothing on the page changes size (Peter's condition, 2026-08-17): the
   words stay where they are and the area around them grows. The bottom margin goes with it,
   so the column keeps its rhythm instead of gaining the padding twice.
   Fine pointers are left alone: a mouse does not need 44px and the footer stays compact. */
@media (pointer: coarse) {
  .col a { padding: 11px 0; margin: 0; }
  .brand, .nav-toggle { min-height: 44px; }
  .nav-toggle { padding-block: 12px; }
}
.footer-bottom { border-top: 1px solid var(--border); padding-top: 20px; font-size: 12px; color: var(--ink-faint); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }

/* ---------- reveal animation ---------- */
/* Visible is the DEFAULT, and the animation is what gets added. It used to be the other way
   round, and with scripting off or a script that failed to run, 28 elements on the home page
   sat at opacity 0 for ever: everything below the hero, gone, with no error anywhere. The
   class on <html> is set by an inline script in the head, so it lands before first paint and
   there is no flash of content appearing and then hiding. */
.reveal { opacity: 1; transform: none; }
html.js .reveal { opacity: 0; transform: translateY(16px); transition: opacity .5s ease, transform .5s ease; }
html.js .reveal.visible { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) { html.js .reveal { opacity: 1; transform: none; transition: none; } html { scroll-behavior: auto; } }


/* ---------- monospace labels ----------
   Small uppercase labels and step numbers only, never body copy.

   font-weight: 600 is mandatory. The mono file is a STATIC 600: anything heavier
   still in effect makes the browser synthesise fake bold, which smears the glyphs.
   The selectors have to out-specify what they replace, which is why the step
   number is '.process-step .num' and not '.num': the existing rule is (0,2,0) with
   font-weight 800 and would otherwise win. This block sits at the end of the file
   so equal-specificity ties resolve in its favour. */
.eyebrow,
.process-step .num,
.blog-card .meta,
.article .meta,
.site-footer .col h5,
.product-card .from,
.pd-price .from,
.photo-count {
  font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-weight: 600;
}

/* Weights stop at 700 on purpose. The IBM Plex Sans we ship is a variable font
   with a wght axis of 100-700, so a declared 800 or 900 is silently clamped to 700:
   measured, 700/800/900 render to the identical width, and no fake bold is
   synthesised. Nothing broke while these said 800, but the stylesheet claimed a
   weight that never reached the screen. If a heavier cut is ever wanted, it needs a
   new font file, not a bigger number. */

/* ---------- responsive ---------- */
/* The header row gained a fourth item when the basket link arrived, and just above the mobile
   breakpoint it stopped fitting: with one item collected, the page scrolled 29px sideways at
   881px and 10px at 900, while an empty basket (link hidden) still fitted. The gap between the
   nav links is the slack in that row, so it gives way rather than a link, the CTA or a word. */
@media (max-width: 1000px) {
  .navlinks { gap: 16px; }
}
@media (max-width: 960px) {
  .services-grid, .products-grid, .blog-grid, .why-grid { grid-template-columns: repeat(2, 1fr); }
  .portfolio-grid { grid-template-columns: repeat(2, 1fr); }
  .problems-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 880px) {
  .hero-inner { grid-template-columns: 1fr; padding: 44px 24px 56px; gap: 36px; }
  h1 { font-size: 31px; }
  .page-hero h1 { font-size: 27px; }
  .navlinks {
    display: none; position: absolute; top: 100%; left: 0; right: 0;
    background: var(--surface); border-bottom: 1px solid var(--border);
    flex-direction: column; padding: 18px 24px; gap: 16px; align-items: flex-start;
  }
  .navlinks.open { display: flex; }
  .nav-toggle { display: block; }
  .nav-cta { display: none; }
  .process-grid { grid-template-columns: repeat(2, 1fr); row-gap: 32px; }
  .process-grid .process-step::after { display: none; }
  .cta-band { flex-direction: column; align-items: flex-start; padding: 38px 28px; }
  .contact-grid { grid-template-columns: 1fr; }
  .pd-grid { grid-template-columns: 1fr; gap: 30px; }
  .pd-info h1 { font-size: 27px; }
  .pd-body { margin-top: 40px; }
}
/* Service rows, two-column band (561-1000px). Deliberately its own query and NOT part of the
   880px block: that block also flips the nav to a burger and stacks several grids, and widening
   it would have moved all of that too.
   The copy column is capped at 520px (.service-row > div:nth-child(2)), so any width past that
   cap is dead space, not room the text needs. The photo takes exactly the leftover, with a 96px
   floor for the narrow end where there is none. Measured 2026-08-23, landscape phone 844x390:
   photo 96 -> 252px, copy column unchanged at 520. At 932px the old fixed 520px photo squeezed
   the copy to 340px; this rule gives the copy its 520 and the photo the remaining 340. */
@media (min-width: 561px) and (max-width: 1000px) {
  /* The copy cap is 400px HERE and 520px on desktop, deliberately. Peter asked for a bigger
     photo in landscape (2026-08-23) and the only place to take it from is the copy column.
     Measured at 844x390: 520px cap gives a 252px photo at 68 characters per line, 400px gives
     a 372px photo at 54. Both sit inside the comfortable 45-75 range, so the wider photo costs
     nothing that matters. A 50/50 split was the next step up (386px photo) and was rejected:
     on a narrower landscape phone (740px) it drops the copy to 44 characters, below the floor. */
  .service-row { grid-template-columns: minmax(96px, 1fr) minmax(0, 400px); }
  .service-row .service-icon-img { width: 100%; height: auto; aspect-ratio: 1 / 1; border-radius: 0; }
}
@media (max-width: 560px) {
  .services-grid, .products-grid, .blog-grid, .why-grid, .portfolio-grid, .problems-grid { grid-template-columns: 1fr; }
  /* the hero form is the tightest thing on a phone: stack name and email so the
     fields keep a usable width instead of two cramped half-columns */
  .page-form-row { grid-template-columns: 1fr; }
  .problems-form { padding: 22px; }
  .page-form { padding: 18px; }
  /* The service row wraps here: photo above, text on the full width. The 96px cap above
     exists because a bigger icon inside a TWO-column row squeezes the copy; once the row
     is a single column that constraint is gone, so the photo takes the full width and
     reads as a photo instead of a thumbnail. Measured 2026-08-23 at 390px: 120px filled
     35% of the 342px column. The paired `sizes` attribute in build.py has to move with
     this rule, or the browser keeps picking the 400px file for a 342px box at DPR 3. */
  .service-row { grid-template-columns: 1fr; }
  .service-row .service-icon-img { width: 100%; height: auto; aspect-ratio: 1 / 1; border-radius: 0; }
  section { padding: 60px 0; }
}

/* ---------- quote basket ----------
   The header's part of it: a link and a count, on every page. The request page itself is
   further down. */
.cart-link {
  display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
  font-size: 14px; font-weight: 600; color: var(--ink-soft);
  padding: 8px 12px; border: 1px solid var(--border); border-radius: 0;
}
.cart-link:hover { color: var(--accent); border-color: var(--accent); }
/* display: inline-flex above BEATS the browser's [hidden] { display: none }, so the JS that hides
   this link on an empty basket had no effect, and every page shipped a header reading
   "Your quote 0" -- the exact noise the hiding exists to avoid. Any element the code hides by
   attribute needs this line next to its own display declaration. */
.cart-link[hidden] { display: none; }
/* Two labels, one shown per width. The short one exists because on the narrowest phones the
   header row is 30px wider than the screen, and a bare count would not say WHAT it counts --
   notification, favourite and quote request all look the same as a number. */
.cart-label-sm { display: none; }
.cart-count {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 22px; height: 22px; padding: 0 6px; border-radius: 0;
  background: var(--accent); color: var(--text-on-accent); font-size: 12px; font-weight: 700;
}
/* The add button reports back in place. .is-added is the confirmation; the JS also uses the
   same element to say when storage REFUSED, because a silent "Added" that added nothing is
   the failure this whole page is meant not to have. */
/* Flat fill and no lit edge: a confirmation is a state, not a button. The flat background
   already out-specifies .btn so the gradient cannot reach it, but the lit edge is cancelled in
   writing rather than by accident. */
.btn.is-added { background: var(--ok); border-color: var(--ok); color: #063; box-shadow: none; }

/* ---------- the request page ----------
   Design's layout, 2026-08-02. The page frame is the contact page's frame (.page-hero,
   .contact-grid, .form-card): the visitor makes the same movement, so it needed no new visual
   language. Only the item row below is new, and it borrows .service-row's shape -- picture on
   the left, everything else beside it. */
.quote-item {
  display: grid; grid-template-columns: 96px 1fr; gap: 16px; align-items: start;
  border: 1px solid var(--border); border-radius: var(--radius); padding: 16px;
  margin-bottom: 12px; background: var(--surface);
}
/* 96px on a phone as well, deliberately (Design): a human answers these requests, and the photo
   is what tells the visitor they collected the right part. Shrinking it on the screen where
   people actually browse would take that away exactly where it is needed. */
/* The same plate as the catalogue, at 96px: 6px of inset rather than the 12 the big cards use,
   because at this size 12 would eat a quarter of the picture (Design). */
.quote-thumb {
  display: block; width: 96px; height: 96px; border-radius: 0; overflow: hidden;
  background: var(--plate); padding: 6px; border: 1px solid var(--line);
}
.quote-thumb img { display: block; width: 100%; height: 100%; object-fit: contain; }
/* A DELISTED item keeps the slot and loses the plate, and that is not a layout choice. The plate
   is the visual form of the one rule this site's photo argument produced: what is on the picture
   is what you get. A delisted item has no such object, so putting it on a plate would break that
   rule in the one place where a visitor is about to ask for a price.
   Solid hairline, not dashed: dashed is the light-UI idiom for "drop something here", and beside
   a real product photo it reads as a broken image rather than a deliberate blank. */
.quote-thumb.is-blank { background: var(--surface-2); border: 1px solid var(--line); padding: 0; }
.quote-item-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; }
.quote-item-name { flex: 1 1 auto; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.quote-item-title { font-size: 16px; font-weight: 700; color: var(--ink); }
a.quote-item-title:hover { color: var(--accent); }
.quote-item-price { font-size: 14px; color: var(--ink-soft); }
/* A word, not an X: next to a note field, an X does not say whether it clears the note or drops
   the item (Design). */
/* The padding is the TARGET, and the negative margin gives the layout back: as a bare text link
   this was 65x16, less than half of any touch guidance, so a deliberate tap missed and the
   visitor had to try again. 77x38 now. The link itself does not move: the GLYPHS were measured
   (a text range, not the box -- the box is what padding changes) before and after, and they sit
   on the same coordinates at 1360 and 390, as does the price beside them. */
.quote-remove {
  flex: none; background: none; border: none; cursor: pointer;
  padding: 11px 6px; margin: -11px -6px;
  font-size: 14px; font-weight: 600; color: var(--ink-soft); text-decoration: underline;
}
.quote-remove:hover { color: var(--danger); }
/* The product left the catalogue after it was collected. The row stays and says so, rather than
   disappearing or standing there with no price. */
.quote-unlisted { margin: -4px 0 12px; font-size: 14px; color: var(--ink-soft); }
.quote-unlisted a { color: var(--accent); font-weight: 600; }
/* Solid, not dashed, and the reason is that the page may only say one thing with a dashed line.
   .quote-empty keeps it, for "there is nothing here at all". This row is not empty -- it has a
   name, a sentence and a Remove button; what it lacks is availability. So: dashed means nothing
   here, a hairline means something here that is an exception. Same rule as the picture slot
   beside it, which lost its dashes for the same reason. */
.is-unlisted { border-style: solid; border-color: var(--line); }

/* Quantity and colour share a line; the note takes the whole width under them. Its label
   ("Anything specific?") does not fit a third of the row, and it is the field most likely to
   carry the thing that decides the quote. */
.quote-item-fields { display: grid; grid-template-columns: 132px 1fr; gap: 12px; }
.quote-field-note { grid-column: 1 / -1; }
.quote-field { display: flex; flex-direction: column; gap: 5px; }
.quote-field label { font-size: 12px; font-weight: 700; color: var(--ink-soft); }
.quote-field .opt { font-weight: 500; color: var(--ink-faint); }
.quote-field input, .quote-field textarea {
  width: 100%; padding: 9px 11px; border: 1px solid var(--field-line); border-radius: 0;
  font-size: 14px; font-family: inherit; color: var(--ink); background: var(--field);
}
/* Three lines: at 342px the placeholder wraps to three, and a placeholder cut off mid-sentence
   reads as a broken box rather than a small one. */
.quote-field textarea { resize: vertical; min-height: 64px; }
.quote-field input:focus, .quote-field textarea:focus { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent); }

/* Minus and plus around the number, because this is the one field every item needs and the
   native stepper is small on a desktop and absent on a phone. The native arrows are hidden where
   they do exist: two ways to do the same thing in 132px is a worse control, not a richer one. */
.qty-stepper { display: flex; align-items: stretch; min-height: 42px; }
.qty-step {
  width: 40px; flex: none; cursor: pointer; background: var(--field); border: 1px solid var(--field-line);
  color: var(--ink); font-size: 18px; line-height: 1; font-family: inherit;
}
.qty-step:first-child { border-radius: 0; }
.qty-step:last-child { border-radius: 0; }
.qty-step:hover { background: var(--bg-soft); }
.qty-step:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.qty-stepper input { border-radius: 0; border-left: 0; border-right: 0; text-align: center; appearance: textfield; -moz-appearance: textfield; }
.qty-stepper input::-webkit-outer-spin-button,
.qty-stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

/* Empty is not a failure here (Design): most of what this business makes is custom, so someone
   standing here with nothing collected is a normal visitor, not a lost one. Hence two ways out
   and not one -- a single "Browse products" would say the catalogue is all we do. */
.quote-empty { border: 1px dashed var(--border); border-radius: var(--radius); padding: 34px 26px; text-align: center; background: var(--surface); }
.quote-empty h2 { margin: 0 0 8px; font-size: 18px; }
.quote-empty p { margin: 0 auto 18px; max-width: 46ch; color: var(--ink-soft); font-size: 16px; }
.quote-empty-ctas { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.form-card-title { margin: 0 0 18px; font-size: 18px; }

@media (max-width: 560px) {
  .quote-item { grid-template-columns: 96px 1fr; gap: 12px; padding: 14px; }
  .quote-item-fields { grid-template-columns: 1fr; }
  /* 44px both ways on the widths where the finger is the pointer. 168 = 44 + 80 + 44, so the
     number keeps room for three digits, which the missing max attribute makes a real case. */
  .quote-field-qty .qty-stepper { max-width: 168px; min-height: 44px; }
  .qty-step { width: 44px; }
  /* padding and gap both, because the count is not one character wide: nothing caps the
     quantity, so "500" is a request we want, and it has to fit on a 360px phone. */
  .cart-link { padding: 6px 8px; gap: 6px; font-size: 14px; }
  /* Same row, same reason as the 1000px rule above: below this width the header holds the logo,
     the basket link and the menu button, and 20px between them is more than there is to spend. */
  .nav { gap: 12px; }
}

/* The way into the calculator from the printing service. A button, because a link inside a
   sentence is what Peter read as "nothing is there"; secondary weight, because Design counted
   what it would be competing with on this page and the answer was two buttons, neither of
   them in a service block.
   The lead line stays, and it is not decoration: without it the button says what it does but
   not the one thing that makes it worth pressing, which is that the file does not leave the
   machine. */
.service-cta-lead { margin-top: 14px; font-size: 16px; }
.service-cta { margin-top: 12px; }
/* On the contact page it stays a sentence, and it sits at the top of the page above the
   form. It was under the form for a day, on my reasoning that somebody who reached this
   page had already decided to ask us. Peter overruled it with the stronger argument: at the
   foot of the page most people never see that the calculator exists at all. A sentence, not
   a button -- the primary call on this page is Send Quote Request and nothing competes. */
.page-hero .hero-aside { margin-top: 14px; font-size: 16px; max-width: 640px; }
.page-hero .hero-aside a { color: var(--accent); font-weight: 600; }

/* The option selects on a product page. Borrowed from .form-row rather than restyled: they
   are the same control doing the same job, and a second appearance for a select would be a
   second thing to keep true. Side by side while there is room, because the two products with
   two options each ask one question about the part and one about its colour, and stacking
   them reads as a form. */
.pd-options { display: flex; flex-wrap: wrap; gap: 14px; margin: 0 0 20px; }
/* The 160px basis was sized for a select, and a select is one box whatever it holds. A tile
   row is as wide as its tiles, so the same basis squeezed the mast holder's six sizes into a
   column one chip wide. Auto lets a row take the width it needs and the wrapping parent still
   puts two short rows side by side. */
.pd-option { display: flex; flex-direction: column; gap: 6px; flex: 0 1 auto; min-width: 0; }
.pd-option label { font-size: 14px; font-weight: 600; color: var(--ink-soft); }
.pd-option select {
  width: 100%; padding: 11px 13px; border: 1px solid var(--line-bright); border-radius: 0;
  font-size: 14px; font-family: inherit; color: var(--ink); background: var(--sunk);
  min-height: 44px;
}
.pd-option select:focus { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent); }
/* ---------------------------------------------------------------- product option swatches
   Design's spec, 2026-08-15, measured rather than judged.

   Square, because the site is: --radius is 0 and nothing else on 42 pages is round. The 1px
   border is load-bearing rather than decorative -- the black swatch measures 1.11 against the
   card, so without an edge the tile IS the card. #6e7180 clears 3:1 in both directions at
   once: 3.48 on the card, 4.25 on the white swatch. --line-bright at 2.46 was too faint.

   Selected is said three times: an amber ring (8.91 on the card, offset so it never sits on
   the fill), a check mark whose colour comes from the palette per swatch (worst case 5.43,
   where a lightness threshold would have put white on green at 3.34), and the colour's NAME
   in the label. The third is the only signal that survives colour blindness. */
/* 44px is the base, and it stays the base: it is a touch target, not a look. The tiles shrink
   to 32 only where the pointer is FINE, which is a mouse, where 32 is comfortably hittable and
   44 read as oversized (Peter, 2026-08-15). Written this way round on purpose -- a browser
   that reports no pointer at all keeps the size a thumb can hit. */
.pd-swatches { display: flex; flex-wrap: wrap; gap: 10px; }
.pd-swatch {
  width: 44px; height: 44px; padding: 0; border: 1px solid #6e7180; border-radius: 0;
  background-color: var(--swatch); cursor: pointer; position: relative;
  display: grid; place-items: center;
}
.pd-swatch[aria-checked="true"] { outline: 2px solid var(--accent); outline-offset: 2px; }
.pd-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pd-swatch svg { width: 20px; height: 20px; display: none; color: var(--swatch-check); }
.pd-swatch[aria-checked="true"] svg { display: block; }
/* AFTER the base rules, not before them. A media query does not raise specificity, so this
   block sitting above .pd-swatch lost the width to it and the tiles stayed 44 on a mouse --
   measured, not spotted by reading. */
@media (pointer: fine) {
  .pd-swatches { gap: 8px; }
  .pd-swatch { width: 32px; height: 32px; }
  .pd-swatch svg { width: 16px; height: 16px; }
}
.pd-swatch--gold { background-image: linear-gradient(135deg,#e3c36a 0%,#c29a3c 48%,#a97b1e 100%); }
/* Two of the name tag's twelve values are several filaments, not one colour, and no single
   tick colour clears the whole tile: white measures 2.63 on the gold band and a dark tick 1.02
   on the black one. So on these the tick carries its own ground -- the tile's darkest stop --
   where it reads at 18.71 and 5.94, both above the palette's own worst case of 5.43. */
.pd-swatch--multi svg { background: var(--swatch-ground); padding: 2px; }
.pd-option .pd-chosen { font-weight: 400; color: var(--ink); }

/* The same component with a word in it. A closed dropdown shows one of six prices and turns
   the other five into a surprise after the click, so the price rides inside the chip -- but
   only where the price actually moves with that option. Everywhere else it is noise. */
.pd-chips { display: flex; flex-wrap: wrap; gap: 10px; }
.pd-chip {
  min-height: 44px; padding: 10px 14px; border: 1px solid var(--line-bright); border-radius: 0;
  background: var(--sunk); color: var(--ink); font: inherit; font-size: 14px; cursor: pointer;
}
.pd-chip[aria-checked="true"] { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 2px; }
.pd-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pd-chip .pd-chip-price { color: var(--ink-soft); margin-left: 8px; }
.pd-chips--stacked { flex-direction: column; }
.pd-chips--stacked .pd-chip { width: 100%; text-align: left; }

/* Name plate only: two colours that have to work together. The preview shows the pairing so
   the visitor can see it; it stopped saying anything ABOUT the pairing on 2026-08-15 (Peter:
   "not our business"). Seeing it was always the better half of the two. */
/* Sized down from full width to 240px (Peter: "the name plate preview is hatalmas nagy").
   The proportion is the plate's and does not move; only the box does. The lettering is set in
   container units so it shrinks WITH the tile rather than with the window -- viewport units
   would have kept 34px text on a 240px plate, which is the thing that looked wrong. The plain
   px line above it is the fallback where container units are not supported. */
.pd-plate-preview {
  display: grid; place-items: center; aspect-ratio: 16/6; margin: 0 0 12px;
  border: 1px solid #6e7180; background: var(--plate-bg);
  max-width: 240px; container-type: inline-size;
}
.pd-plate-preview span {
  color: var(--plate-fg); font-weight: 700;
  font-size: 27px;
  font-size: 11cqw;
  letter-spacing: .04em; text-transform: uppercase;
}
@media (max-width: 560px) {
  /* On a phone the column is narrower than 240 anyway, so the tile takes the row and the
     lettering follows it down by itself. */
  .pd-plate-preview { max-width: 100%; }
}
.pd-swatch-note { font-size: 12px; color: var(--ink-soft); margin: -6px 0 18px; }

/* The customer's own words, not a choice from a list. Full width and left aligned, because it
   is the one control on the page they compose rather than pick, and on the name tags it is the
   thing without which nothing can be made. */
.pd-textfield { display: flex; flex-direction: column; gap: 6px; flex: 1 1 100%; }
.pd-textfield label { font-size: 14px; font-weight: 600; color: var(--ink-soft); }
.pd-textfield input {
  width: 100%; max-width: 420px; padding: 11px 13px; border: 1px solid var(--line-bright);
  border-radius: 0; font-size: 14px; font-family: inherit; color: var(--ink);
  background: var(--sunk); min-height: 44px;
}
.pd-textfield input:focus { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent); }
/* Said next to the field that is empty, not as a page-level alarm. */
.pd-textfield.is-missing input { border-color: var(--danger); }
.pd-text-warn { font-size: 14px; color: var(--danger); font-weight: 600; margin: 6px 0 0; }

/* What the visitor chose, on the request page, under the name it belongs to. Without it a
   request for six mast holders says nothing about which six. */
.quote-item-options { font-size: 14px; color: var(--ink-soft); margin: 2px 0 0; }
/* The line under the hero buttons. Two sizes on purpose, and the gap between them IS the
   point: "Already have an STL file?" is the setup and stays at the body 14, the link is what
   the line exists for and takes the 18 step, so it stands out of the sentence rather than
   matching it (Peter, 2026-08-17: "only that short text should have been made bigger, so it
   stands out from the rest").

   That is the correction of two earlier rounds on the same line, both of which moved the
   wrong thing. The first raised the paragraph to 16 and left the link at 14, because
   .card-cue brings its own font-size and a size on the child is not overridden by one on the
   parent. The second made the link match the paragraph at 16, which is exactly the emphasis
   Peter was asking for, removed. A line where the aside and the call to action are the same
   size has no call to action in it.

   Both sizes are stated here rather than inherited, because what this line needs is a fixed
   DIFFERENCE, and an inherited size makes that difference a side effect of whatever the
   paragraph is set to next.

   The padding is what makes the link a 44px touch target, and it lives here instead of in
   three inline style attributes on the page. The negative margins cancel that padding in the
   flow, so the line sits where it sat and only the hit area is bigger.

   Scoped to .calc-note-hero: .card-cue on the product grid, the service cards, the problem
   cards and the product pages keeps its own 14 and does not know this rule exists. */
.calc-note.calc-note-hero { font-size: 14px; margin: 0 0 24px; }
.calc-note-hero .card-cue {
  font-size: 18px; padding: 11px 0; margin: -7px 0 -15px 4px; display: inline-block;
}

/* One figure in the kiwifruit orchard article is a 415px crop scaled up, so beyond about
   900px the softness stops being invisible and becomes the thing you look at. Capped rather
   than replaced: it is the only frame of that moment there is. */
.step-narrow img { max-width: 900px; }

/* The line under the product grid. Its own class rather than a change to .calc-note, which
   12 other pages use at 14px and would all have moved with it. (Measured 2026-08-17 on the
   bundle: 16 plain .calc-note elements across 12 pages, every one of them 14px. The numbers
   in this comment used to read thirteen pages at 13.5px, which was true before the type scale
   landed and quietly false after it. A stale number in a comment is the same trap as a stale
   number in a report.)

   Set at the size of the card TITLES, measured at 16px on the 23 cards above it: at the old
   13.5 it read as a footnote to the grid rather than as a way out of it (Peter, 2026-08-16).

   The 19px is the card's own inset, not a chosen number: 1px border plus the 18px padding of
   .product-card .body. It puts this line's first letter on the same vertical as every card's
   title above it. If that padding ever changes, this follows it or the alignment breaks.

   The spacing either side is 56px, and it is a new number: this stylesheet has no spacing
   scale, only raw values, and the nearest neighbours in use are 52 and 60. 56 is what the
   line needed to stop looking abandoned between 28px above and 160 below. The 160 was not a
   margin at all -- it was two section paddings meeting -- so the pair below is split across
   the boundary, 28 of dark and 28 of grey, which also leaves the grey block something above
   the band rather than having it start on the border. */
.calc-note.calc-note-grid {
  font-size: 16px; text-align: left; padding-left: 19px; margin: 56px 0 0;
}
.products-list { padding-bottom: 28px; }
.products-close { padding-top: 28px; }

/* What the customer typed, set apart from what they picked: one is their words, the other is
   a value off a list, and a request that blurs the two is a request Peter has to interpret. */
.quote-item-typed { font-size: 14px; color: var(--ink); margin: 2px 0 0; }

/* ---------------------------------------------------------------- price calculator

   One page uses these. They are here rather than in the page because the site has one
   stylesheet and a second one would be a second thing to keep true; the whole block is
   under 2 KB, which is less than the request it would cost to fetch separately.

   The four panels (drop, working, result, other) occupy the same slot and swap with the
   hidden attribute, so they share a min-height: without it the page jumps by the height
   difference between "choose a file" and a three-line explanation, right at the moment
   the visitor is reading it. */
.calc { max-width: 660px; margin: 0 auto; }
.calc-panel {
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); padding: 34px 28px; text-align: center;
  min-height: 268px; display: flex; flex-direction: column; justify-content: center; gap: 14px;
}
.calc-panel[hidden] { display: none; }
/* The panels take the focus when they replace one another, so a keyboard or screen-reader
   visitor is not sent back to the top of the page at every step. They are not controls
   though -- tabindex is -1, nobody tabs to them -- and the site's focus ring drew a full
   orange box around the card on every transition, which reads as an error state. The focus
   stays, the ring goes; it beats the site-wide rule on specificity, so order cannot undo it. */
.calc-panel:focus, .calc-panel:focus-visible { outline: none; }
/* Disabled only under file://, and it has to look chosen rather than broken: the faded
   gradient it gets by default is muddy and still looks pressable. */
.calc-zone .btn[disabled] {
  background: var(--sunk); border-color: var(--line); color: var(--ink-soft);
  box-shadow: none; cursor: not-allowed;
}
.calc-zone {
  border: 1px dashed var(--line-bright); padding: 26px 18px;
  display: flex; flex-direction: column; align-items: center; gap: 14px;
  transition: border-color .12s ease, background .12s ease;
}
/* The drag-over state has to be visible from the corner of the eye, because the visitor
   is looking at the file they are dragging, not at the page. Its own alpha rather than
   --accent-soft: that token is tuned for a resting tint under text, and at 0.14 behind a
   dashed edge it reads as a filled panel rather than a target. */
.calc-zone.is-over { border-color: var(--accent); background: rgba(255,171,46,0.08); }
/* Said before the visitor picks anything, not after it silently fails. */
.calc-warn {
  border: 1px solid var(--accent); border-left-width: 3px;
  padding: 12px 14px; margin: 0; text-align: left;
  font-size: 14px; color: var(--ink);
}
.calc-warn[hidden] { display: none; }
.calc-title { font-size: 18px; font-weight: 600; margin: 0; }
.calc-or { font-size: 14px; color: var(--ink-soft); margin: 0; }
.calc-note { font-size: 14px; color: var(--ink-soft); margin: 0; }
.calc-stage { font-size: 16px; font-weight: 600; margin: 0; }
/* Fills over the sixty seconds the page is willing to wait, so the bar is the budget
   rather than a guess at progress the engine cannot report. */
.calc-track { height: 4px; background: var(--surface-2); border: 1px solid var(--line); }
.calc-track span { display: block; height: 100%; width: 0; background: var(--accent); transition: width .2s linear; }
/* Three fixed choices, drawn as one segmented control rather than three loose buttons:
   the join is what says "pick one of these", which a row of separate buttons does not.
   44px minimum height because this is the one control on the page a thumb has to hit. */
.calc-strength { display: flex; flex-direction: column; align-items: center; gap: 9px; }
.calc-strength-label { font-size: 14px; font-weight: 600; margin: 0; }
.calc-strength .calc-note { max-width: 42ch; }
.calc-chips { display: inline-flex; border: 1px solid var(--line-bright); }
.calc-chip {
  font-family: inherit; line-height: inherit; font-size: 14px; font-weight: 600;
  padding: 10px 18px; min-height: 44px; border: 0; cursor: pointer;
  background: linear-gradient(180deg, var(--sunk), var(--sunk-deep));
  color: var(--ink-soft); transition: color .12s ease;
}
.calc-chip + .calc-chip { border-left: 1px solid var(--line-bright); }
.calc-chip:hover { color: var(--ink); }
.calc-chip.is-on {
  background: linear-gradient(180deg, var(--accent-lit), var(--accent-mid));
  color: var(--text-on-accent);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
}
.calc-band { font-size: 44px; font-weight: 700; color: var(--accent); margin: 0; line-height: 1.1; }
.calc-band-label {
  font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 12px;
  letter-spacing: .08em; text-transform: uppercase; color: var(--ink-soft); margin: 0;
}
.calc-other h2 { font-size: 21px; margin: 0; }
/* The heading stays centred, the paragraph does not. Centred body copy is fine for one
   line and ragged on both edges by the seventh, which is what these are on a phone. */
.calc-other p { color: var(--ink-soft); font-size: 16px; margin: 0; max-width: 46ch; align-self: center; text-align: left; }
.calc-attach { font-size: 14px; }
.calc-ctas { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-top: 6px; }
/* The explainer cards borrow .problem-card for its look, but there they are links. A card
   that lifts and lights up under the cursor and then does nothing reads as a broken button,
   so the hover is taken back off for the three that are only text. */
.calc-how .problem-card:hover { border-color: var(--border); transform: none; box-shadow: none; }

@media (max-width: 560px) {
  .calc-panel { padding: 26px 18px; }
  .calc-band { font-size: 31px; }
  /* Same reason as the product page CTAs below: on a phone these are the two things the
     visitor is here to press. */
  .calc-ctas > .btn { flex-basis: 100%; }
}

/* Mobile: the two CTAs each take the full width. Until 2026-08-02 this row held a payment
   column, a flex item that never grew -- it sat at 209px on a 360, 390 and 430px screen alike,
   which is how you could tell the width was the widget's, not a chosen one. The column is gone;
   the full-width rule is not, because the reason survives it: on a
   phone these are the two things the visitor is here to press, and a 221px button in a 342px
   row reads as an unfinished edge rather than a hierarchy signal.
   Placed last in the file deliberately -- an earlier @media (max-width: 560px) block sits above
   the .pd-ctas base rules and would lose to them. */
@media (max-width: 560px) {
  .pd-ctas > .btn { flex-basis: 100%; }
}

/* The narrowest phones. The header row carries the logo, the basket link and the menu button,
   and on a 320px screen it wanted 344. Both sides give a little rather than one side a lot:
   the wordmark goes 138 -> 120 wide (35px tall, not small), and the basket says "Quote" instead
   of "Your quote", which the page it opens repeats in its own H1 anyway.
   Not a bare count pill: a number alone does not say what it counts, and this is the width with
   the fewest other clues on screen. Not the logo alone either: the full label needs it down at
   104px, which costs the brand more than the word costs the sentence.
   Last in the file because the 560px rules above set the same properties. */
@media (max-width: 360px) {
  .brand img { height: 35px; }
  .cart-label { display: none; }
  .cart-label-sm { display: inline; }
  .nav { gap: 10px; }
}
