/* TextScaleHQ — base.css (tokens, header, footer, blog/page/404 — loaded sitewide)
   Generated from preview v3.25.4. Do not hand-edit in isolation:
   the preview file remains the design source of truth. */


/* ============================================================================
   TEXTSCALEHQ — main.css
   Mobile-first. Breakpoints: 700px (tablet), 1024px (desktop).
   All color tokens can be overridden via WP Customizer (see functions.php).
   ----------------------------------------------------------------------------
   v3.24.0 (Footer directory ported from the theme. Written as "v3.21.0" on
   the theme branch, textscalehq 2.0.0; that number collides with the
   v3.21.0 truth-up in this file, so it is filed here under v3.24.0.)

   v3.24.0 additions: J. Compliance (#compliance) joins the Product column.
   K. The 3px .tsh-footer-accent strip under the bottom bar is removed,
      markup and CSS. It painted --brand-grad, which the theme's
      customizer re-set to a gradient, and read as a stray blue line.

   Original note, as written on the theme branch:
   (Footer: 5 links -> 6-column directory)

   The footer carried five in-page anchors and nothing else. No privacy
   policy, no terms, no contact, no support. Three of those are hard
   blockers: Stripe checkout and 10DLC brand registration both require a
   live privacy URL, and carrier vetting rejects sites with no reachable
   contact route.

   A. STRUCTURE. .tsh-footer-main (brand rail + one nav column) is gone,
      replaced by .tsh-footer-cols, a six-column grid: 2 up on phones,
      3 up from 700px, all six from 1120px. Six columns plus a brand rail
      does not fit inside the 1240px inner without labels wrapping, which
      is why the brand block was cut rather than repositioned.

   B. REMOVED, deliberately. The footer brand mark (a ~2KB inline SVG,
      duplicated from the header), the wordmark, the tagline and the BYOC
      pill. The old tagline read "zero inbox bloat", which becomes false
      the day the two-way inbox ships, so it was not worth rewriting for
      a slot being deleted. Company name now survives in the copyright
      line only. If the footer ever needs a mark again the cheap fix is a
      24px logo left of the copyright, not a restored brand rail.

   C. SOCIALS moved into the bottom bar and reduced 34px -> 32px so they
      do not outweigh 12px text. A duplicate Privacy/Terms/Contact row and
      a mail icon were both drafted here and both cut: every one of those
      links is already visible in the Legal and Company columns directly
      above, and these columns do not collapse on mobile. Revisit ONLY if
      the six columns are ever made into mobile accordions, at which point
      a persistent legal row starts doing real work again.

   D. LINKS THAT ARE REAL. #platform, #pricing, #calculator, #faq, #login
      carry over. #ai-promo and #multi-business are newly linked and are
      live sections. #use-cases was NOT used: it exists only in the
      v3.19.0 changelog note, the section was deleted.

   E. PLACEHOLDERS. Of 36 footer links, 27 are href="#" with data-route
      recording the intended path, so wiring them later is a grep for
      data-route rather than a hunt. 7 are marked data-must and block
      launch:
        /privacy-policy   Stripe + 10DLC registration
        /terms            Stripe
        /acceptable-use   carrier vetting
        /sms-policy       10DLC campaign approval (opt-in + STOP handling)
        /refunds          Stripe requires a visible cancellation policy
        /contact          carrier vetting
        /support          carrier vetting
      The remainder (docs, blog, changelog, status, about, the five
      /for/* vertical pages, the three social profiles) can 404-free
      launch by not rendering until the page exists.

   F. OPEN. The Solutions column is five pages that do not exist and the
      use-cases section they would have echoed was removed in v3.19.0.
      Kept because they earn their keep as SEO landing pages, but the
      whole <nav aria-label="Solutions"> block is safe to delete as one
      unit if launch comes first. Same for the three social icons: if the
      accounts are not live, delete .tsh-footer-social entirely rather
      than shipping icons that lead to empty profiles.

   G. .tsh-footer-inner top padding clamp(2.5rem,5vw,3.5rem) ->
      clamp(3rem,5.5vw,4rem). Without the brand row the column headings
      sat too close under the CTA band's hard edge.

   H. PRE-EXISTING, not introduced here: href="#login" resolves to nothing.
      It was already dead in v3.20.6 in the header, the mobile menu and the
      old footer nav. Carried forward as-is so this diff stays footer-only;
      it needs the real login route in the same pass as /signup.

   I. Footer stays white. The dark variant (#0f172a field, #cbd5e1 links,
      rgba(255,255,255,.09) rules) was mocked and reads better against
      the blue band, but it is a page-level call, not a footer one.
   ----------------------------------------------------------------------------
   v3.20.6 (Section 06 mobile — the column labels were never rendering)

   Reported from a phone: in the stacked layout you cannot tell which line
   is the competitor and which is us.

   A. Cause — a specificity loss, present since the mobile rules were
      written, not introduced by the v3.20.4 rewrite.
      .tsh-compare-table tbody td::before  -> (0,1,2)  content: attr(data-label)
      .tsh-compare-bad::before             -> (0,1,0)  content: "Bundled platforms"
      .tsh-compare-good::before            -> (0,1,0)  content: "TextScaleHQ"
      The generic rule outranks both, so every cell resolved
      content: attr(data-label). No <td> in the file has ever carried a
      data-label attribute, so that evaluates to an empty string: two
      invisible ::before boxes per row, and no labels.
   - Under 720px the thead is display:none, so those ::before labels were
      the ONLY thing naming the columns. Losing them left colour as the
      sole signal — which also fails for anyone colour-blind, and red/green
      is the worst possible pair for that.

   B. Fix — re-scoped to .tsh-compare-table tbody td.tsh-compare-bad::before
      = (0,2,2), which beats the generic rule. Chosen over adding
      data-label="..." to ten <td>s: same result, no markup churn, and the
      literal strings stay next to the colours they belong to.
   - The generic attr(data-label) rule is left in place. It is the correct
      default for any future cell that does supply the attribute.

   C. Accent rail per cell
   - Even with labels back, two stacked lines of body copy in a dark panel
      read as one block. Each td now gets a 2px left rail and 12px of
      padding — neutral by default, tinted to the cell's own colour for
      bad/good. Gives the stacked rows the column edge the table had.
   - Mobile only; inside the existing max-width:720px block. Desktop
      untouched.

   D. Not changed
   - Row 6 of the audit list in this file still says the desktop thead
      reads "Bundled SMS platforms" while the mobile label reads "Bundled
      platforms". Left alone — the mobile label is tighter and the wording
      difference is invisible, since only one renders at a time.
   ----------------------------------------------------------------------------
   v3.20.5 (AI Promo tier truth — closes the v3.19.2 OPEN item)

   Source: qrn-ai-promo-engine v0.9.7, QRN_AI_Promo_Engine_Tiers::TIER_LIMITS
   (L3089). Authoritative, not inferred.

                     starter    growth      pro
     plans/month           1         4       10
     regens/month          5        40      200
     max_plan_weeks        4        12       52
     plan lengths        [4]    [4,12]  [4,12,52]
     cadences/week       [1]     [1,2]    [1,2]

   A. The conflict v3.19.2 logged
   - Headline: "Describe it once. 52 weeks of promos."
     Eyebrow directly above it: "Included on every plan."
     Both sentences are individually true. Side by side they assert
     something false — that every plan reaches 52 weeks. Starter reaches
     4. That is a 13x gap a buyer discovers after paying.
   - v3.19.2 proposed either narrowing the eyebrow to the tier that gets
     52 weeks, or dropping the headline to the number Starter can reach.
     Taking neither: the first buries the ceiling that makes the feature
     impressive, the second throws away the best number on the page.
   - Instead the eyebrow now carries the ladder: "4, 12 or 52 weeks by
     plan". Same slot, same length class, and it still says "every plan"
     implicitly by naming all three. The headline keeps its 52.
   - Headline markup deliberately untouched. The v3.19.2 note documents
     the measured fit — widest line 467px against a 579px 17ch box at the
     3rem cap. "Up to " pushes that line to roughly 660px and it wraps to
     three lines. The fix belongs in the eyebrow, which has slack.

   B. The bigger miss this exposed
   - AI Promo is the most tier-differentiated capability in the product:
     4/12/52 weeks, 1/4/10 plans per month, 5/40/200 regens. Nothing else
     splits that hard.
   - It appeared on ZERO pricing cards. An entire page section sells it,
     and the place where people actually choose a tier never mentioned it.
     Added to all three with the real week caps.
   - This also corrects the v3.20.1 note claiming branded QR was "the only
     hard product differentiator Pro carries on this page". It was not.
     AI Promo is a far larger one and was simply invisible in pricing.

   C. Third tick replaced
   - Was "Editing and reshuffling is free" — the second of three
     reassurances. For the one feature no competitor has, two hedges out
     of three reads apologetic.
   - Now states catalog grounding, which is real and verifiable:
     catalog_grounding_directive() (engine L3354) instructs the model to
     spotlight specific named items from the uploaded catalog, and the
     system prompt at L3293 forbids inventing a price, discount or offer
     that was not supplied. That is the substantive difference between
     this and a ChatGPT box bolted onto a compose field.

   D. Sub carries the ladder too
   - Skimmers read headline and eyebrow; readers read the sub. Both now
     tell the same story.

   E. Still open, flagged not fixed
   - Trial users resolve to the STARTER tier (engine L3120-L3133:
     sub_status 'trialing' and mode 'trial' both return 'starter'). So a
     30-day trialist evaluating the product gets 4-week plans, 1 per
     month, 5 regens. If AI Promo is the feature that closes the sale,
     the trial shows its weakest form. That is a product decision, not a
     copy one, so nothing here changes it.
   - Plans-per-month and regens-per-month appear nowhere on the page or
     in the pricing cards. Adding week caps without them is honest but
     incomplete; a Starter user gets one plan per month and cannot learn
     that before paying.
   ----------------------------------------------------------------------------
   v3.20.4 (Section 06 repurposed — the second-year comparison)

   A. What was wrong with it
   - 06 was a competitor comparison sitting four sections after 03, which
     is also a competitor comparison. 03 wins that fight outright: it is
     interactive, it names TextMagic, EZ Texting, SimpleTexting, SlickText
     and TextMagic Connect, and it moves real per-message rates against a
     slider. 06 answered with five rows of adjectives against a column
     header reading "Bundled SMS platforms".
   - Every cell in the "bad" column hedged — "often awkward, expensive, or
     capped", "polished but expensive — or raw and confusing". Hedging is
     what a comparison table cannot afford; its whole job is to be
     definite.
   - Rows 1 and 3 (Message pricing / Carrier flexibility) were both BYOC
     stated twice.
   - Not a single number appeared in the table. Adjectives are free and a
     competitor can copy them onto their own page in an afternoon.

   B. What it does now
   - 03 compares what you pay this month. 06 compares what happens to you
     over the following twelve, which is the thesis the section 01
     sub-headline sets up ("credits that expire... while your bill grows
     with every contact you add") and which nothing else on the page paid
     off. The two sections now do different jobs instead of the same job
     twice.
   - Every row carries something checkable. Sources are the competitor
     research done for v3.20.0-v3.20.1 (credit expiry windows, per-seat
     pricing, location-tier pricing, annual-contract termination terms)
     and, for our column, the audited limits: 2/5/unlimited businesses
     from qrn_billing_business_limit_for_plan.
   - Closing row is the carrier account. That is the one claim structurally
     unavailable to any bundled platform — under BYOC the Twilio account,
     the numbers and the message history already belong to the customer,
     so there is nothing to hold hostage on the way out. Strongest
     argument on the page and it had never been stated.

   C. A row that got cut during fact-checking
   - The draft had "Adding a teammate — $10-30/user/mo vs included".
     Competitor side is accurate. Our side is not: grep for
     seat/team member/invite across the dashboard, billing engine and
     leads plugin returns zero. There is no multi-user support to include.
   - Left out rather than softened. Worth flagging separately though:
     EZ Texting and Podium both meter seats, which means both HAVE seats.
     For the agency ICP, where staff logins are assumed, that is a real
     gap rather than a pricing difference.

   D. Shell untouched
   - .tsh-compare, .tsh-compare-glow, .tsh-compare-table, .tsh-compare-bad
     and .tsh-compare-good are unchanged, still five rows, still three
     columns. No CSS added or removed. This is a copy change.
   ----------------------------------------------------------------------------
   v3.20.3 (Pro -> "Unlimited sends"; Starter/Growth keep their numbers)

   The deciding evidence was the in-app meter, not the billing constants.

   A. Pro: "1M sends/mo" -> "Unlimited sends"
   - qrn_billing_message_cap_is_soft() returns true for 'pro', and
     message_cap_check() can only return 'block' when
     $mode === 'hard' && ! $is_soft. Pro cannot be blocked by a flag
     flip; it would take a code change. That is what "unlimited" means.
   - The dashboard agrees. qrn_dash_messages_meter_ctx (dashboard L9788)
     puts Pro in the 'soft' level with state "Fair use" and the hint
     "Never blocks — fair-use ceiling." A Pro user who reads "unlimited"
     here and then opens Account sees a consistent story.
   - The 1,000,000 figure stays real and stays disclosed — it just lives
     in the meter and the policy section, which is where a fair-use
     ceiling belongs.

   B. Starter and Growth keep 25,000 and 100,000
   - Not a legal call. The product itself refutes "unlimited" for these
     two. The Account tab renders "<used> / 25,000" with a progress bar
     and escalating states: "Getting close" at 60%, "Near limit" at 85%,
     and at 100% the level goes to 'danger' with the literal string
     "You've reached this month's send allowance." (dashboard L9797-9807)
   - So a Starter buyer told "unlimited" on this page meets a hard
     denominator inside the product within days of signing up. That is
     worse than either claim on its own: it is not a disclosure gap, it
     is the app calling the landing page a liar.
   - Keeping the numbers also keeps the strongest comparison on the page.
     25,000 sends for $29 against a competitor's 500 credits for $29 is
     50x, checkable in thirty seconds, and believed. "Unlimited" is the
     least persuasive word in SaaS pricing precisely because everyone
     uses it and buyers assume the asterisk.

   C. Fair-use footnote under the plan grid
   - One line, quiet, pointing at the policy section. Covers the Pro
     ceiling and the Starter/Growth allowances in the same breath so
     there is a single place the fine print lives.

   D. Calculator
   - TIERS.msgs for Pro set to 0 as the sentinel for "no numeric cap";
     the receipt builder now renders "Unlimited sends" for it and the
     formatted number for the other two.
   ----------------------------------------------------------------------------
   v3.20.2 (CORRECTION — "Unlimited sends" was not true)

   Audited against QRN_Billing_Engine v0.5.1, qrn-outbound-queue-engine
   v0.5.3, qrn-sms-manager-v2 v0.6.0.

   A. The claim v3.20.0 introduced was wrong
   - qrn_billing_message_limit_for_plan (Billing L2006) defines a real
     monthly message allowance per account: starter 25,000, growth
     100,000, pro 1,000,000. Unknown/trial falls to the Starter number.
   - It is wired into the live send path. qrn_sms_manager_v2 L4496 calls
     qrn_billing_message_cap_check() at campaign launch and returns false
     on a 'block' decision.
   - It does not block TODAY only because qrn_message_cap_mode defaults
     to 'soft' (Billing L2158): warn at 80%, never block. One
     update_option('qrn_message_cap_mode','hard') turns it on for
     Starter and Growth. Pro stays warn-only in every mode
     (message_cap_is_soft, L2090).
   - So "Unlimited sends" described a dormant flag, not a product
     promise. That is the exact claim that turns into refund requests
     the day the flag flips. Replaced with the real numbers.

   B. The real numbers are a better pitch than "unlimited" anyway
   - Starter is $29 for 25,000 sends/month. The nearest competitor's $29
     tier buys 500 message credits. That is 50x, it is checkable, and a
     buyer will believe it. "Unlimited" reads as puffery and gets
     mentally discounted by exactly the audience we want.
   - Limit rows now read "<contacts> · <sends>/mo", sends emphasised,
     same .tsh-plan-limit strong treatment added in v3.20.0.
   - TIERS in the cost calculator gains a `msgs` field so the receipt
     meta stays in lockstep with the cards instead of being hand-typed.

   C. MMS — the v3.20.1 caveat is RESOLVED, no revert needed
   - Queue engine L3158 gates on ! qrn_billing_plan_allows_mms($plan),
     not on a tier comparison. Its own changelog (L21) states
     'mms_requires_growth' is now effectively dead.
   - qrn_billing_plan_allows_mms (Billing L2078) returns true
     unconditionally for every plan including trial.
   - sms-manager v0.5.4 moved the recurring-runner MMS skip onto the same
     helper. The surviving growth||pro comparison at L4557 is inside
     handle_plan_applied_for_mms_unblock — legacy release machinery for
     campaigns blocked under the old rule, not a gate.
   - MMS is universal end-to-end. The Starter card and the cost-section
     note both stand as written.

   D. Scheduling — confirmed ungated, and the card was wrong
   - No scheduling gate exists in any of the three engines. "Scheduled
     campaigns" was sitting on Growth alone, so it has been added to
     Starter and Pro. It was the only feature line Growth had that
     Starter did not.

   E. What the tiers actually differ on, in full
   - Contacts:   500 / 2,500 / unlimited      (Billing L1548)
   - Businesses: 2 / 5 / unlimited            (Billing L2040)
   - Sends/mo:   25k / 100k / 1M soft         (Billing L2006)
   - Branded QR: Pro only                     (dashboard L56950)
   - Support tier
   That is the whole list. Every other capability is identical on every
   plan. "Every plan gets every feature, you are only picking a size" is
   now a true statement with exactly one asterisk (branded QR), and it is
   a stronger position than a feature matrix. Not restructured here —
   flagged as the next copy decision.

   F. Cleanup this surfaced elsewhere (not in this file)
   - qrn_dash_campaigns_humanize_block_meta (dashboard L23583) still maps
     "mms requires growth" to "MMS requires Growth plan / upgrade to
     send". Nothing can emit that reason any more, so it is dead copy on
     a live modal. v0.51.2 built an icon variant for it, so the dead path
     is being actively maintained.
   - In-app plan modal (dashboard L65336) still says "1 business account"
     for Starter against an enforced 2, and carries none of the send
     allowances.
   ----------------------------------------------------------------------------
   v3.20.1 (Reconciled against qrn-dashboard.php v0.51.5)

   Source of truth for everything below is the dashboard file as audited,
   not the roadmap notes, which had drifted several versions behind.

   A. "Ultimate" -> "Pro" across all 6 surfaces on this page
   - The app renamed this tier in dashboard v0.43.3, across all ten
     customer-facing surfaces. $qrn_plan_label_map (L11902) and the
     account plan modal (L65405) both read 'pro' => 'Pro'. This landing
     page was the last place still saying "Ultimate", so a prospect who
     bought Ultimate would log in to a product that had never heard of it.
   - Changed: plan card h3, the TIERS array in the cost calculator, the
     two static receipt strings it hydrates, and the MMS note.
   - The internal slug stays 'pro'. Slug and label now agree, so the
     long-standing tier-name mismatch is closed from this side.

   B. Starter business accounts 1 -> 2
   - qrn_dash_business_limit_info (L21646) maps starter => 2, growth => 5,
     pro => -1, and the Billing Engine agrees. We were advertising one
     business account while the gate hands out two.
   - This was under-claiming a paid feature, which is the rarer and more
     annoying direction of wrong: a solo operator with a second location
     had no reason on this page to think Starter covered them.
   - NOTE: the in-app plan modal (dashboard L65336) still says "1 business
     account" too. Same fix needed there; not in this file's scope.

   C. QR codes claimed for the first time
   - They are built. Dashboard Share tab renders two live QR surfaces via
     qrcode-generator 1.4.4: a web-form QR, and a keyword QR encoding
     sms:+1XXXXXXXXXX?body=KEYWORD so a scan opens the customer's SMS app
     pre-filled with the business number and keyword. Basic black-on-white
     is universal to all tiers; Pro adds brand-color tint, center logo
     overlay, and SVG/JPG export on top of the universal PNG.
   - This page claimed none of it, on a product named QR Neighbor, while
     two competitors ship QR codes on their entry tier. The v3.20.0 note
     that said "QR codes are not built" was simply wrong and is corrected
     below.
   - Branded QR is a hard product differentiator for Pro. (Corrected in
     v3.20.5: this note originally said "the only" one. AI Promo splits
     4/12/52 weeks by tier and is the larger differentiator — it was just
     absent from the pricing cards, which is why it was missed here.)

   D. MMS availability note reconciled — READ BEFORE SHIPPING
   - The cost-section note read "Available on Growth and Ultimate" while
     the pricing cards (as of v3.20.0) say every tier gets SMS + MMS. The
     page contradicted itself; that is now resolved in favour of the cards.
   - BUT the underlying product state is genuinely ambiguous and this edit
     assumes the more permissive reading:
       · Composer gate, dashboard L49318 — qrn_billing_plan_allows_mms(),
         explicitly fails OPEN, comment says "MMS unlocked on every tier
         (incl. trial)". Starter can compose MMS.
       · In-app plan modal, L65335 — Starter card reads "SMS + MMS".
       · Send-time gate, qrn-outbound-queue-engine L~2811 — per the
         comments at dashboard L6277 and L23580, still unlocks only on
         plan === 'growth' || plan === 'pro'.
       · qrn_dash_campaigns_humanize_block_meta (L23583) still maps the
         "mms requires growth" error to a friendly upgrade modal, and
         v0.51.2 was actively extending that path (new 'mms' icon
         variant). That is maintained code, not a legacy stub.
     If the queue gate is still live, Starter users compose an MMS, hit
     send, and get an upgrade wall — worse than never offering it.
   - VERIFY BEFORE LAUNCH:
         grep -n "growth" qrn-outbound-queue-engine.php | sed -n '1,40p'
     If the gate is still there, either remove it or revert both this note
     and the Starter card to Growth-and-up.

   NOT changed here, still open:
   - "Scheduled campaigns" sits on the Growth card only. No scheduling gate
     exists anywhere in the dashboard file; the recurring-campaign gate
     lives in sms-manager-v2 L~3372 and is growth||pro. If plain scheduling
     is ungated, Starter is being under-sold again. Needs the same grep
     treatment as MMS.
   - "Multi-business reports & analytics" sits on Pro only. Reports are not
     tier-gated anywhere in the dashboard, and Growth already gets 5
     businesses, so Growth users have multi-business reports too. Defensible
     as positioning, not defensible as a feature gate.
   ----------------------------------------------------------------------------
   v3.20.0 (Step 5 truth-up — competitive claims + unlimited sends)

   A. Switching strip: "Twilio Console" -> "Podium"
   - Twilio was a category error in a strip labelled "for teams outgrowing
     BUNDLED SMS platforms". Twilio is the unbundled carrier layer, not a
     bundle. Worse, the carriers strip further down lists Twilio as the
     provider you bring — so the same page framed one brand as both the
     thing you are leaving and the thing you are built on.
   - Nothing is lost by the swap: problem card 3 already carries the
     correct Twilio critique ("great at delivering your texts, but...").
   - Podium replaces it because it is the most literally bundled product
     in the category (webchat + reviews + payments + SMS), it is priced
     per LOCATION which sets up problem card 2 for free, and its bulk
     messaging allowance is small enough that the contrast is self-evident.
   - No CSS change needed: "Podium" is narrower than "Twilio Console", so
     the <=599px repeat(2, auto) grid and the 600px+ single row both still
     fit. See the v3.18.4 note for why that breakpoint is scoped as it is.

   B. Problem sub-headline rewritten
   - WAS: "...or duct-tape together forms, contacts, campaigns, and
     reporting across too many screens."
   - That clause was false against three of the four brands named in the
     strip immediately above it. SimpleTexting, EZ Texting and TextMagic
     all ship forms, contacts, campaigns and reporting in one dashboard;
     several include them on their entry tier. Anyone who has actually
     used the named competitors would read that line, conclude we have
     not, and discount the two accurate clauses next to it.
   - NOW leads on metering instead of fragmentation, which IS true across
     all of them and is the thing we actually beat: their plans are
     denominated in credits that expire, and the bill scales with list
     size. Also sets up pricing section 07 and problem card 1.

   C. "Unlimited sends" on all three plan limit rows
   - The contact count was the only number on each card, so it read as
     THE constraint with nothing countering it. Competitors price the
     same headline number in message credits, not contacts — one of them
     is $29 for 500 CREDITS against our $29 for 500 CONTACTS. Identical
     price, identical number, opposite unit, and ours is far the better
     deal. A prospect scanning both tables will not work that out unless
     the card says so.
   - .tsh-plan-limit rows now read "<n> contacts · Unlimited sends", with
     the second half emphasised via a new .tsh-plan-limit strong rule
     (--ink, 700) so the eye lands on the permission, not the cap.
   - "Up to" dropped from Starter and Growth. It is a shrinking word in
     the one slot on the card where we want expansion.
   - Same string applied to #receiptPlanMeta and to the JS that rebuilds
     it in the cost calculator, so the checkout receipt cannot contradict
     the pricing card. "· BYOC" dropped from that meta line: the Twilio
     line item directly below it already carries both a "passthrough" tag
     and an "at cost" meta, so BYOC was the third statement of one fact.

   D. Starter MMS lock removed (live inaccuracy)
   - The card advertised "SMS only" with a lock chip reading "MMS locked".
     MMS went open to every tier including trial in Billing v0.5.0
     (qrn_billing_plan_allows_mms, three gates swapped to fail open).
     The page was selling a restriction the product no longer has.
   - Starter now matches Growth: "SMS + MMS".
   - .tsh-plan-lock / .tsh-plan-lock svg removed as orphan CSS — that chip
     was its only consumer anywhere in the file. Same housekeeping as the
     v3.4 .scroll-dots removal.

   NOT changed here, still open:
   - Tier name "Ultimate" against the 'pro' slug used throughout the app.
     Needs a direction decision before the copy can be truthed up.
   - QR codes: this note originally read "not built". That was wrong —
     see the v3.20.1 entry above. Corrected rather than deleted so the
     error stays greppable.
   ----------------------------------------------------------------------------
   v3.19.4 (Hero mockup visible on first paint)
   - SYMPTOM: land on the page and everything below the tick row is blank
     until you scroll, so the first screen reads as unfinished.
   - CAUSE: two entrance mechanisms were fighting. .tsh-mockup-wrap has
     animation: tshFadeUp 800ms 500ms, the last beat of the hero load
     cascade (0 / 100 / 200 / 300 / 400 / 500ms). But .heroD-stage inside
     it also carried data-reveal, and html.js-reveal [data-reveal] pins
     opacity to 0 until the observer fires. The wrap animated in on load
     around an invisible child.
   - MECHANISM: the observer uses threshold 0.15 with rootMargin
     0px 0px -8% 0px. .heroD-stage is a fixed 600px tall, 860px from
     1000px up, so 90px of it must sit inside the shrunk viewport on
     mobile and 129px on desktop. Whether that happened came down purely
     to viewport HEIGHT, which is why the bug looked intermittent.
   - Measured, stage top is ~619px on any desktop width (the hero is a
     single centred column from 1024px, so width barely moves it):
         1280x720   49px visible   BLANK
         1366x768   88px visible   BLANK
         1280x800  123px visible   BLANK   (missed by 6px)
         1440x780   99px visible   BLANK   (windowed)
         1440x900  209px visible   showed
         1536x864  176px visible   showed
         1920x1080 375px visible   showed
     So short laptops were blank and tall displays were fine. On a
     390x844 phone the stage starts ~690px down against a 777px trigger
     edge, leaving ~87px against 90px needed: missed by roughly 3px.
   - FIX: data-reveal and data-reveal-delay removed from .heroD-stage. It
     was redundant with the wrapper animation and made the mockup the only
     part of the hero on the scroll system. Everything else in the hero
     already animates on load, so this is a consistency fix as much as a
     visibility one. Note this is unconditional: it removes the gate at
     every breakpoint rather than tuning a threshold that would have
     stayed one copy edit away from breaking again.
   - VERIFIED after the change: zero data-reveal attributes remain
     anywhere in the hero subtree, and no JS references heroD at all, so
     the mockup is now driven purely by CSS on load.
   - NOT changed: hero spacing. The mockup still starts around 690px down
     on a 390x844 phone, so roughly its top 150px shows. That is a peek
     rather than the whole dashboard, which is the intended reading, but
     if more should show the lever is the subhead length, not the padding.
   ----------------------------------------------------------------------------
   v3.19.3 (Closing CTA: contained card -> full-bleed band)
   - The card was the least designed moment on the page and also the
     conversion point: flat brand-2 while everything around it uses
     gradients and layered depth, dots tiling uniformly with no mask, and
     on a phone a single white button left floating mid-field by
     justify-self:start under 24px of gap and up to 3.5rem of padding.
   - Also fixed a real bug, not a style one: the button was
     href="#trial" sitting inside id="trial", so the primary conversion
     element scrolled the page to itself. It now points at /signup, which
     is a placeholder and needs the real route.
   - STRUCTURE: the section is now a sibling of .tsh-footer-inner rather
     than a child. The inner caps at 1240px and pads, which is exactly
     what kept the old card contained, so full bleed is impossible from
     inside it. #trial stays on the band, it is the scroll target for the
     hero and section 06 CTAs.
   - .tsh-footer border-top dropped: the band supplies its own hard edge.
     .tsh-footer-inner top padding reduced from clamp(3rem,6vw,4.5rem) to
     clamp(2.5rem,5vw,3.5rem), since it was spacing the old card and the
     band now owns that.
   - CONTENT: second action added. One button gave anyone not ready to
     sign up nowhere to go but the footer nav. Both actions go full width
     below 640px so nothing floats.
   - SUPERSEDES the v3.18.5 note that .tsh-footer-cta-sub was already
     flush left. It is centred now, inside a centred band.
   - data-reveal moved from the section to .tsh-footer-cta-inner, so the
     gradient field is always painted and only the content animates in.
   - OPEN: the sub promises "Full Starter access" during the trial, while
     section 04 now headlines "52 weeks of promos", which is top tier
     only. As written, the trial does not include the feature the page
     just spent a section selling. Same tier problem tracked in v3.19.0
     and v3.19.2, but this is where a prospect actually hits it.
   ----------------------------------------------------------------------------
   v3.19.2 (Section 04 headline)
   - Copy: "Describe your business once. Get a year of promos." becomes
     "Describe it once. 52 weeks of promos."
   - WHY the old one broke on mobile: the first sentence measured 420px in
     Inter 800 at the 1.9rem clamp floor, against a 320px budget on a
     360px phone. It could not fit at any phone width, so it wrapped to
     two short lines while the gradient span, being an inline-block and
     therefore unbreakable, held one long line. Short, short, long.
   - Shrinking the type to fit was rejected: it needed 1.45rem, and
     sections 01, 05 and 06 all floor at 2rem, so it would have read as
     27 percent smaller than every neighbouring headline.
   - Fixed as a character budget instead. Both sentences now hold one line
     at every step of the clamp, verified against real font metrics:
     467px widest line vs a 579px 17ch box at 3rem, and 296px vs a 320px
     budget at the 1.9rem floor.
   - .tsh-aip-hl-ln added to make each sentence a block. text-wrap:balance
     dropped from the stacked-layout rule, since nothing wraps any more.
   - OPEN, and now sharper than it was: the headline promises 52 weeks,
     but the dashboard gates 52-week plans behind the top tier while the
     eyebrow says "Included on every plan". Either the eyebrow narrows to
     the tier that actually gets 52 weeks, or the headline drops to the
     number Starter can reach. See the v3.19.0 note on the Pro versus
     Ultimate tier-name mismatch, which is the same underlying problem.
   ----------------------------------------------------------------------------
   v3.19.0 (Section 04 replaced: Use cases -> AI Promo Assistant)
   • WHY: the page was long and the use-cases section was the most
     restateable part of it — "restaurants send promos, salons send
     reminders" is already implied by the hero and section 02. AI Promo
     is the one thing on the roadmap no competitor ships, so it earns the
     slot the use cases were spending.
   • Slotted at 04 deliberately. 05-08 keep their numbers, and nothing in
     the nav or the mobile menu pointed at #use-cases, so the removal is
     a clean excision rather than a renumbering pass.
   • REMOVED, markup and CSS together, no orphan rules left behind:
       - <section class="tsh-cases" id="use-cases"> and both of its
         comment banners
       - the "=== USE CASES ===" block (.tsh-cases*, .tsh-case*,
         .tsh-device*, .tsh-notif*)
       - the industry-icon, trust-strip and "also used for" blocks
       - the USE-CASES HALF ONLY of the mobile h-scroll query. That query
         is shared with .tsh-pricing-grid, so it is trimmed, not deleted.
       - .tsh-cases-sub from both subhead-alignment lists. .tsh-aip-sub is
         deliberately NOT added in its place: this section sets its own
         alignment at its own breakpoint (see below).
   • REUSES existing components instead of reinventing them — this was the
     difference between the section reading as native and reading as
     bolted on:
       - .tsh-kicker / .tsh-kicker-num  (black section pill)
       - .mw / .mw-titlebar / .mw-dots / .mw-dot / .mw-url  (browser frame,
         same one section 02 uses — an earlier pass had a second, subtly
         different frame with grey dots, which was the giveaway)
       - .tsh-ticks / .tsh-tick-icon  (hero green-check row), with a
         (0,2,0) .tsh-aip-copy-scoped override to kill the hero's
         load-time tshFadeUp and its always-centred auto margins
   • ALIGNMENT breakpoint-split, same reasoning as v3.18.6: the copy
     centres below 1024px where it stacks full width, and goes flush left
     from 1024px up where it is the left column of a two-column grid.
   • MOBILE: this section does NOT follow the "product shots, not
     responsive UIs" bleed rule documented at the .tsh-step-visual query.
     Two reasons, both specific to this screen:
       (a) the real app restacks — qrn-dashboard.php has
           @media (max-width:900px){.qrn-aip-planbody{grid-template-columns:1fr}}
           so the stacked layout IS the phone product shot; a cropped
           desktop composition would show a screen no phone user sees.
       (b) the payoff of the animation is the Draft -> Scheduled pill
           flip, and those pills sit on the right edge of each message
           card. Bleeding at 720px puts them behind the fade.
     So phase 3 restacks (week rail becomes a horizontal strip) and
     phase 1 drops the plan-length radios to keep the CTA on screen.
   • OPEN ITEM: the dashboard gates 52-week plans behind a tier it calls
     "Pro", but the pricing section sells Starter / Growth / Ultimate.
     The section copy sidesteps it by naming no tiers, but the in-app
     lock pill is advertising a plan that does not exist on this page.
     Related: the "Included on every plan" line is only true if Starter
     gets 4-week plans. If Starter is locked out entirely it needs to
     read "Growth and up".
   ----------------------------------------------------------------------------
   v3.18.10 (Section 05 mockup — stat counts reconciled with the row count)
   • ALL and ACTIVE stat values changed 7 -> 5. The list renders 5 .mn-row
     entries and, since the v3.18.9 footer removal, there is no pagination
     affordance to explain a 7/5 gap. The numbers now simply agree.
   • Chosen over the alternative (adding 2 more rows to actually show 7):
     two characters vs. new markup, no change to card height, and no change
     to the proportions of the 720px bled mobile render where only part of
     the card is visible anyway. 5 rows still reads unambiguously as
     "several clients in one dashboard", which is the section's only job.
   • SETUP and TRASHED stay at 0 — consistent with all 5 rows showing the
     green Active pill.
   • Closes the open item logged in v3.18.9. The section 05 mockup is now
     internally consistent: 5 stat / 5 rows / 5 Active pills / 0 setup /
     0 trashed.
   ----------------------------------------------------------------------------
   v3.18.9 (Remove the section 05 mockup footer band)
   • Removed the .mn-card-foot "Showing 1-5 of 7 businesses" row added one
     version earlier, markup and CSS together — no orphan rule left behind.
   • The v3.18.8 search-field width and the "New" -> "New Business" label
     are unaffected and stay.
   • OPEN ITEM this re-exposes: the stat cards still read ALL 7 / ACTIVE 7
     while the list renders 5 rows, and there is now no pagination
     affordance to explain the gap. Two ways to close it, neither applied
     yet pending a call:
       (a) change both stat values 7 -> 5. Two characters, fully consistent,
           no height change. 5 rows still reads clearly as "multiple
           businesses" for a section titled Multi-business.
       (b) add 2 more .mn-row entries so the list actually shows 7. Keeps
           the larger number but adds ~84px of card height, which also
           changes the proportions of the 720px bled mobile render.
   ----------------------------------------------------------------------------
   v3.18.8 (Section 05 Businesses mockup — fidelity pass against the real app)
   • Reference: screenshot of the live app.textscalehq.com/businesses page.
     Three gaps closed, four deliberately left open (below).
   • Search field: .mn-search-input was flex:1, so it stretched and the
     Search button landed hard against the card's right edge, aligned under
     the New button. The real page uses a short fixed-width input with the
     Search button beside it and the whole right side of that row empty.
     Changed to flex:none + width:200px. 200px is ~20% of the mockup card's
     ~994px inner width at desktop, which is the same input-to-card ratio
     the real page uses (~398px of ~1990px). It also stays legible in the
     720px mobile render, where the card inner is ~474px and the field
     reads as ~42% — the placeholder needs ~145px minimum at 11px with the
     32px icon inset, so there is real headroom.
     NOTE: the earlier argument for keeping it stretched (that the right
     edge alignment with the New button was load-bearing, and shortening
     would leave an awkward empty pocket) was wrong. The real page has
     exactly that empty pocket and reads fine.
   • Button label: "New" -> "New Business". .mn-btn-primary is flex:none so
     the card head absorbs the extra width with no reflow.
   • Footer band: new .mn-card-foot showing "Showing 1-5 of 7 businesses",
     matching the real page's "Showing 1-4 of 4 businesses". Full-bleed
     inside the card via margin:12px -16px -16px against the card's 16px
     padding, so its edges land on the inner edge of the card border, with
     bottom corners radiused to match. This also fixes the inconsistency
     flagged in the previous session: the stat cards read ALL 7 / ACTIVE 7
     while the list rendered 5 rows with no pagination affordance. The
     footer makes 5-of-7 explicit rather than looking like a miscount.
   • Deliberately NOT matched (mockup renders at a fixed 720px with 11px
     type, and on mobile its right half sits past the fade — legibility at
     that scale beats literal fidelity):
       - Row metrics stay 2 (Contacts / Campaigns); real has 3 (+ Web Forms)
       - Stat labels stay "All" / "Setup"; real has "All Businesses" /
         "Needs Setup", which would crowd those cards at 11px
       - Card subtitle stays short; the real one is a full line at 11px
       - All rows stay Active; real data has two "Needs setup" rows with
         "Messaging not configured". Accurate, but a landing page should not
         advertise a half-configured state. Same reasoning that makes
         "Riverside Coffee Co." the right choice over "dummy business test".
   ----------------------------------------------------------------------------
   v3.18.7 (Hero tick row wrapping 2+1 on desktop)
   • Symptom: at desktop the three hero ticks broke as "No per-message
     markup from TextScaleHQ" + "Multi-business dashboard" on row 1, with
     "Twilio + Vonage support" alone and centred on row 2.
   • Cause: .tsh-hero-copy is capped at max-width:760px inside the 1024px
     query, but .tsh-hero-inner around it is 1240px. Three ticks plus two
     28px gaps exceed 760px, so the third wraps. Nothing else in the copy
     block was using that 760px: .tsh-headline self-caps at 16ch,
     .tsh-subhead at 56ch, and .tsh-ctas is a centred flex row of two
     buttons that already fits. The cap was only ever squeezing the ticks.
   • Fix: 760px -> 1040px, still comfortably inside the 1240px parent.
     ~280px of extra room for the tick row; headline, subhead and CTAs are
     unaffected because they are all capped or centred independently.
   • Coverage at the low end: at a 1024px viewport the hero's own padding
     (clamp(1.25rem,4vw,2rem) = 32px each side) leaves 960px, so the copy
     block resolves to min(1040,960) = 960px there — still ~200px more than
     it had. The whole >=1024px range gains room; no band regresses.
   • Not changed: the 28px desktop column gap on .tsh-ticks. It was offered
     as belt-and-braces but the cap raise alone should clear it at every
     desktop width. If the 1024-1100px band still wraps, dropping
     gap: 12px 28px -> 12px 24px in the v3.14 ticks query buys another 8px.
   • Rejected alternatives: trimming "from TextScaleHQ" off tick 1 (fits
     inside the old 760px with no CSS change, but that phrase is what
     separates platform markup from carrier cost — it earns its width), and
     stacking the ticks as a centred column on desktop like mobile (never
     wraps, but loses the compact trust-bar look and makes the hero taller).
   ----------------------------------------------------------------------------
   v3.18.6 (Subhead alignment — split at the breakpoint where the box stops
   being narrower than its container)
   • v3.18.5 applied text-align:left to the 8 subheads at every breakpoint.
     Correct on desktop, wrong on phones, and the reason is geometry rather
     than taste:
       - Desktop: the paragraph box is capped at 48-60ch (~456-480px) inside
         a much wider container, so margin:0 auto centres the BOX and the
         flush-left lines sit inside a visibly centred column. Both
         properties at once.
       - Phone: at 393pt the container is ~353px, already narrower than the
         48-60ch cap, so the box fills the column edge to edge. There is no
         leftover space for auto margins to centre anything. Flush-left
         lines then sit under a centred kicker and a centred headline and
         read as a mismatch.
     The crossover is ~500-520px viewport. Split at 700px — the file's
     existing tablet breakpoint, and comfortably past the crossover so the
     box is never within ~150px of filling the container at the boundary.
   • >=700px: text-align:left (v3.18.5 behaviour, unchanged).
   • <700px: back to inheriting text-align:center from the intro block,
     plus text-wrap:balance.
   • On text-wrap:balance — centred text CANNOT have aligned edges. Centring
     works by giving each line equal space on both sides, so a short line is
     inset on both. That is what centring is, not a fixable side effect. The
     only two ways to get lines that share an edge are left (flush left,
     ragged right) and justify (flush both). Justify was rejected for phone
     width: the column runs ~40 characters and justification wants 45+, so
     long tokens ("TextScaleHQ", "implementation") force visible word-space
     gaps that look worse than the ragging. balance is the honest middle
     ground — it does not align the edges, it equalises the LINE LENGTHS so
     the rag is shallow and symmetric instead of random.
   • balance degrades safely: Chromium caps it at a line count (6 at time of
     writing) and unsupported browsers ignore the declaration entirely. In
     both cases you get plain centred text, i.e. the v3.18.4 rendering. No
     layout risk.
   • If you want true edge alignment on phones after seeing this, the swap is
     two lines in the same block: text-align:justify + text-align-last:center,
     with hyphens:auto to soften the gaps. Say the word.
   ----------------------------------------------------------------------------
   v3.18.5 (Section subhead paragraphs — flush-left lines, all breakpoints)
   • Every section subhead inherited text-align:center from its parent
     intro block (.tsh-hero-copy, .tsh-problem-intro, .tsh-how-intro,
     .tsh-calc-intro, .tsh-cases-intro, .tsh-agency-copy,
     .tsh-compare-intro, .tsh-pricing-intro). Centred text means every
     wrapped line starts and ends at a different x. On desktop these
     paragraphs run 2 lines and it reads fine; on a phone they wrap to
     4-6 lines and the ragged left edge is obvious. Eight sections of it.
   • Fix: text-align:left on the 8 subhead paragraphs themselves. A rule
     that targets the element directly always beats a value inherited
     from an ancestor, regardless of specificity, so no !important and
     no restructuring of the intro blocks is needed.
   • The paragraph BOX is deliberately untouched — every one of these
     keeps its max-width (48-60ch) and margin:0 auto, so the block still
     sits centred under the centred headline. Only the lines inside it
     go flush left. Position unchanged, lines now aligned.
   • Applied at ALL breakpoints, not just mobile. Desktop subheads are
     shorter but still wrap 2-3 lines inside a 48-60ch box, and the same
     ragged left edge applies there.
   • Kickers and headlines stay centred. Only the body paragraphs moved.
   • Implemented as one grouped rule at the end of the stylesheet rather
     than 8 scattered edits: it is a single page-wide typographic
     decision, so keeping it in one labelled block makes it trivial to
     review or revert. No other rule in the file sets text-align on any
     of these 8 selectors, so placement carries no cascade risk.
   • NOT changed (call these if you want them): .tsh-pricing-foot (the
     13px "All plans include a 30-day free trial..." footnote under the
     pricing grid) and .tsh-carriers-jump — both short centred footnotes
     rather than section subheads, and both outside the 01-07 scope.
     .tsh-footer-cta-sub was already left-aligned, no change needed.
   ----------------------------------------------------------------------------
   v3.18.4 (Mobile alignment + overflow pass)
   • Hero ticks (.tsh-ticks) — the mobile grid used justify-items:center,
     which centres each <li> INDIVIDUALLY. Because the three tick labels
     are different lengths, the three green checkmarks landed at three
     different x positions (ragged left edge). Now the <ul> itself is
     width:fit-content + margin auto (so the block still centres as a
     unit) with justify-items:start, so the checkmarks stack in one
     column. Desktop (>=1024px) flex row is unaffected — fit-content of
     a single-line flex row equals its content width, so auto margins
     produce the identical centred result.
   • Switching strip (.tsh-switching-list) — 4 items in a centred
     flex-wrap broke 2x2 on phones, and because row 1 (SimpleTexting /
     EZ Texting ~246px) and row 2 (TextMagic / Podium ~256px, v3.20.0)
     are different widths, the centred rows sat at different offsets
     and the items formed no columns. Below 600px it is now a
     repeat(2, auto) grid with justify-items:start, so the two rows
     line up as columns. Column gap clamped (20-36px) so it still fits
     a 320px viewport. Scoped to <=599px deliberately: the 4 items need
     ~538px on one row and a 700px tablet offers ~644px of content
     width, so 600-899px never wrapped and keeps the untouched centred
     flex row. >=900px keeps the right-aligned desktop layout.
   • Section 05 header (.tsh-agency-copy) — the centring rules lived
     INSIDE @media (min-width:1024px), so below 1024px section 05 was
     the only section on the page with a left-aligned kicker, headline
     and sub while 01-04 and 06-08 were all centred (every other
     *-intro block sets text-align:center unconditionally). Hoisted
     text-align / max-width / auto-margins out of the query. The 36px
     bottom margin stays inside the query — at mobile the .tsh-agency-inner
     grid gap already supplies the spacing, and adding 36px on top made
     a ~76px hole between the copy and the feature list.
   • Section 05 CTAs (.tsh-agency-ctas) — centred at all widths to match
     the hero CTAs, which have always been centred on mobile. Previously
     "See pricing" sat flush left under a (now) centred headline.
   • Feature list (.tsh-agency-feats) deliberately LEFT-aligned still —
     long wrapping lines read better ragged-right, and it matches the
     how-it-works step copy pattern.
   • Section 03 header (.tsh-carriers-head) — REAL clipping bug. Two
     white-space:nowrap labels ("BRING YOUR OWN CARRIER" ~181px and
     "YOUR RATE. YOUR BILL." ~165px at 11px/0.14em tracking) plus 2x16px
     gap need ~378px, but a 393pt iPhone only offers 353px of content
     width and a 360px Android offers 320px. The flex:1 hairline
     collapsed to zero and the tagline was cut off the right edge —
     invisibly, because body{overflow-x:hidden} swallowed the overflow
     rather than producing a scrollbar. Below 560px the head now stacks
     centred and the hairline is hidden.
   • Sticky-header anchor offset — .tsh-header is position:sticky;top:0
     (~70px tall) and html had scroll-behavior:smooth but no
     scroll-padding-top. Every jump link (#platform, #calculator,
     #use-cases, #multi-business, #pricing, #faq, plus the in-page
     "See the math" / "See pricing" buttons) parked the target section's
     kicker and headline BEHIND the header. Added
     html{scroll-padding-top:84px} = 70px header + 14px breathing room.
   • Bleed margins normalised — .tsh-fleet-as-mockup (section 05 mockup),
     .tsh-cases-grid and .tsh-pricing-grid (mobile snap carousels) all
     bled out at -24px, but the section padding is
     clamp(1.25rem, 4vw, 2rem) which bottoms out at 20px on phones —
     a 4px overshoot past the viewport on each side, silently clipped by
     body{overflow-x:hidden}. Card alignment was never wrong (the inner
     padding compensated the negative margin), this is overflow hygiene
     only. All three now use -1.25rem, matching how-it-works (L2497),
     with their compensating padding and scroll-padding moved to 1.25rem
     in the same edit so the alignment contract holds.
   • body{overflow-x:hidden} (L452) left as-is ON PURPOSE. It is against
     the house rule (prefer overflow-x:clip, hidden breaks position:sticky)
     but it is NOT breaking the sticky header here: html sets no overflow,
     so body's value propagates to the viewport and body itself computes
     to visible. Swapping to clip is safe only once the bleed overshoot
     above has been verified in a real browser — otherwise clip would
     expose whatever hidden was masking. Flagged, not changed.
   • Dead CSS NOT removed this pass — see note below.
   • Housekeeping: dropped the empty .tsh-footer-nav {} no-op rule.
   ----------------------------------------------------------------------------
   v3.18.4 NOTE — "orphan" CSS re-assessed
   • .tsh-page-inner, .tsh-article-*, .tsh-post-*, .tsh-404-* do not appear
     in this preview's markup, but this file is the source for the theme's
     main.css and those classes belong to OTHER WP templates (single.php,
     page.php, archive.php, 404.php). They are not orphans. Left intact.
   • .tsh-manifesto-* and .tsh-cases-trust-* are genuinely unreferenced
     here and MAY be retired sections, but that is a content decision, not
     a layout fix — left intact pending a call. ~110 lines combined.
   ----------------------------------------------------------------------------
   v3.18 (How-it-works section — full redesign, single responsive tree)
   • Retired the desktop zig-zag flow + the 54px floating brand-blue
     numbered circles + the vertical timeline rail. Retired the
     entirely separate mobile timeline tree (.tsh-how-mobile /
     .tsh-htl-* + the .s3i-* mini-mockup chrome). Replaced both with
     a single responsive section using the same markup for desktop
     and mobile, framed differently per viewport.
   • Architecture (matches the multi-business section pattern):
       - Centered intro (kicker + headline + sub), max-width 720px
       - Vertical stack of 4 steps. Each step is a 2-col grid:
         copy LEFT (1fr), mockup RIGHT (1.5fr).
       - No alternation. Same composition contract for all 4 steps —
         the Stripe / Linear / Vercel / Cal.com feature-row pattern,
         not the 2018-era zig-zag.
       - On mobile <760px, dashboard mockups (steps 2 & 4) keep their
         desktop composition and bleed past the right viewport edge
         with a 72px white-to-transparent fade — same Linear-style
         overflow-clip pattern the multi-business section already uses
         (.tsh-fleet-as-mockup at L4159). Modal mockups (steps 1 & 3)
         just render at natural width since they're inherently compact.
   • Step indicator: was a 54px floating brand-blue circle with a
     numeric inside, anchored to a vertical timeline rail. Replaced
     with an editorial marker — large brand-blue numeric ("01") beside
     a small uppercase label ("STEP ONE"), with a thin hairline rule
     underneath, all left-aligned in the copy column. Reads more like
     a magazine section number than a feature pill.
   • Step 04 phone preview rebuilt from scratch:
       - 130px → 170px wide, taller (~290px screen content)
       - Modern iOS dark-mode chat: Dynamic Island, real status bar
         with signal/wifi/battery SVG icons, iMessage-style chat
         header (28px circular avatar with brand-gradient + sender
         name), centered timestamp pill ("Today 9:41 AM"), incoming
         dark-gray bubble (#2c2c2e — true iOS dark mode color) with
         16/16/16/4 corner radius for the bottom-left tail, "Delivered"
         status in 8px uppercase 40%-white, and the gesture home bar
         at the bottom.
       - Pulls its visual weight alongside the campaign form instead
         of reading as a placeholder phone-shaped box.
   • Reveal cascade tightened: was 300 / 380 / 460 / 540ms per step.
     New cascade is 0 / 60 / 120 / 180ms for steps and 0 / 80 / 160ms
     for the intro elements. About 5× faster. Pop reads as snappy
     without losing the on-fast-scroll cascade effect.
   • JS: step03ImportAnim IIFE simplified to desktop-only — the mobile
     branch with .s3i-* selectors removed entirely along with the
     mobile mockup tree. The mobile bleed pattern shows the SAME
     desktop animation playing inside the bled mockup as you scroll
     past step 3 on mobile.
   • Net delta: ~605 lines markup retired + ~750 lines CSS retired,
     ~330 lines markup added + ~360 lines CSS added. File ~−900 lines.
   • Cross-refs verified intact: #platform anchor still hit by 3
     internal links (header nav + footer nav). Section position
     between .tsh-carriers and .tsh-calc unchanged.
   • Dead code retired with this section (was unreferenced even in
     v3.17): .tsh-how-grid + ::before, .tsh-how-foot (both leftover
     from a pre-timeline 3-step grid version).
   ----------------------------------------------------------------------------
   v3.12.11 (Mobile How-it-works steps — missed in v3.12.5 reveal pass)
   • Bug: in v3.12.5 when the scroll-reveal system was added, the
     annotation script targeted only the desktop step containers
     (.tsh-step and .tsh-step.is-reversed). The mobile timeline uses
     a completely separate markup tree with .tsh-htl-step containers,
     which were never annotated. Result: on mobile, desktop steps got
     fade-up reveals but mobile steps just appeared with no animation
     when scrolled to — inconsistent with the rest of the page.
   • Fix: added [data-reveal] + [data-reveal-delay] to all 4
     .tsh-htl-step containers with the same delays used on desktop
     (300 / 380 / 460 / 540ms). When multiple steps land in viewport
     simultaneously (fast scroll) they cascade together; on slow
     vertical scroll where one step is visible at a time, the delay
     is per-step and just represents the initial fade-in pause.
   • No CSS or JS changes — the existing scrollReveal IIFE handles
     these new annotations the same way it handles every other
     [data-reveal] element on the page.
   ----------------------------------------------------------------------------
   v3.12.10 (Pricing button artifact + FAQ group reveal)
   • Pricing — Growth "Start free trial" button had a faint dark
     horizontal line near the bottom on mobile. Two contributing
     causes, both addressed:
       1. .tsh-plan-cta.is-primary inherited the base "1px solid"
          border-style and overrode only the color to transparent.
          On some mobile renderers a 1px transparent edge over a
          gradient anti-aliases as a faint dark sliver. Replaced
          with border: 0 — no edge to alias.
       2. Box-shadow was 0 8px 20px -6px rgba(11,92,255,.5) — heavy,
          and its upper band sat right at the button's lower edge
          where the gradient is darkest (#0b5cff). The combined
          band read as a horizontal "line" on phone screens.
          Tightened to 0 4px 14px -4px @ 0.35 alpha — still has
          presence, no banding artifact. Hover shadow scaled to match.
     Also added -webkit-tap-highlight-color: transparent to .tsh-plan-cta
     so iOS doesn't flash a dark overlay on tap.
   • FAQ animation — was firing per-item as each scrolled into view,
     reading as "things popping in one at a time." Generalized the
     group-reveal pattern from v3.12.6 (which was mobile-h-scroll only)
     to a new always-on attribute [data-reveal-group]. Applied to
     .tsh-faq-list. When the FAQ list container enters viewport, all 8
     items reveal together (each keeps its own data-reveal-delay so the
     cascade still plays — 60ms apart, finishing in ~620ms — but starts
     from the same moment).
     JS logic now: closest('[data-reveal-group]') checked first
     (any viewport), then closest('[data-reveal-h-scroll]') on mobile
     as a fallback. Both routes share the same group-trigger callback.
   • [data-reveal-group] is reusable — drop it on any container whose
     children you want to reveal together instead of individually.
   ----------------------------------------------------------------------------
   v3.12.9 (Kicker cleanup — remove last separator dot)
   • The middle "·" separator inside the section kickers ("01 · THE PROBLEM")
     was left in during v3.12.4 because the only ask at the time was the
     leading indicator dot. With the rest of the cleanup pass behind us,
     the separator now reads as the last unnecessary glyph on the badge.
   • Removed:
     - 8 instances of <span class="tsh-kicker-sep">·</span> from section
       kickers in markup (sections 01–08 incl. is-dark variant for 06)
     - Orphan CSS rule .tsh-kicker-sep (light + dark)
   • New format: "01 THE PROBLEM" — flex gap:8px on .tsh-kicker handles
     the spacing between number and label, no manual whitespace needed.
   • Comparison mockup (section_header_compare.html) updated to match —
     Variants 1, 4, 5 (all pill-based) now show the cleaned-up baseline
     so the comparison against Variants 2 and 3 is fair.
   ----------------------------------------------------------------------------
   v3.12.8 (Step 03 Import Contacts animation — "premium moment" pass)
   • Honest note: the Step 03 mockup is a CSV import dialog, not a contact
     list — so the original "rows appearing one-by-one" pitch didn't fit
     the actual UI. Pivoted to animate the form filling itself in as if
     someone is using the feature, ending with a count badge ticking
     0 → 1,247 on the Import button. Same spirit, fits the mockup.
   • Markup:
     - .ic-modal[data-step-import="desktop"] marks desktop mockup
     - .tsh-mw-body[data-step-import="mobile"] marks mobile mockup
     - <span class="ic-foot-cta-count"> / .s3i-btn-count added inside
       both Import buttons as the counter slot
   • Sequence (timing relative to IO fire):
     - 400ms: drop zone accepts "subscribers.csv" (border + bg shift,
       drop title morphs)
     - 800ms: "Business" select fills from "Select business…" →
       "Riverside Coffee" with .is-filled styling
     - 1200ms / 1380ms (desktop only): two checkboxes auto-check with
       a bouncy cubic-bezier(0.34, 1.56, 0.64, 1) scale-in
     - 1700ms desktop / 1300ms mobile: count badge scales in,
       counter ticks 0 → 1,247 over 900ms (ease-out-cubic)
   • Mobile mockup has no checkboxes (simpler UI), so the mobile
     sequence is 3 stages instead of 5, and finishes ~500ms sooner.
   • Trigger: IntersectionObserver threshold 0.2, rootMargin -5% bottom.
     Fires when ~20% of the mockup is in viewport. One-shot, unobserves
     after fire. Separate observer from scrollReveal — different
     concerns, different threshold.
   • Reduced motion: snapEnd() applies the end state instantly with no
     transitions. All target morphs done as final-text-only.
   • Perf: same JIT layer pattern as v3.12.7. Only the elements
     actively transitioning at any given moment carry composite-layer
     hints (via existing CSS transitions, no new will-change).
   • Not animated this pass (deliberate, per prior turn analysis):
     - Step 01 (Create business): no new info to convey
     - Step 02 (Connect carrier): polish ahead of webhook auto-config
       fix on Phase 0 roadmap — would overpromise
     - Step 04 (Send blast): throughput-claim fix on must-fix list —
       fast send animation would amplify the overpromise
   ----------------------------------------------------------------------------
   v3.12.7 (Mobile scroll-jank fix — will-change cleanup)
   • Problem: scroll lag on mobile, worst around the hero (which sits over
     a 40px-blur radial glow) and noticeable again around the calculator.
     Triggered by always-on will-change applied to 57 [data-reveal]
     elements, which forced the browser to pre-allocate a GPU compositor
     layer for every reveal target — even ones already off-screen or
     already animated. Layer count + mobile GPU memory pressure =
     dropped scroll frames over heavy-paint sections (hero radial blur,
     calc card shadows, mockup SVGs).
   • Fix:
     - CSS: removed permanent `will-change: opacity, transform` from the
       always-on [data-reveal] rule. Added a transient .is-revealing
       class that carries the will-change hint only while the animation
       is in flight.
     - JS scrollReveal(): introduced a reveal(el) helper that:
         1. Adds .is-revealing (layer promotion just-in-time)
         2. Waits one rAF, then adds .is-revealed (transition fires)
         3. Listens for transitionend on opacity, then removes
            .is-revealing (layer can be discarded)
       At most a handful of elements carry the will-change hint at any
       moment — peak GPU layer count drops from ~57 to ~3-5.
     - Group-reveal path (mobile h-scroll containers) also routes through
       the same helper so stagger cleanup is consistent.
   • Behavior unchanged: same 600ms fade-up, same easing, same stagger.
     Only the layer-allocation strategy is different — purely a perf fix.
   • MDN note: this is the documented correct usage of will-change.
     Apply just before the change, remove after. Never permanent on
     selectors that match many elements.
   ----------------------------------------------------------------------------
   v3.12.6 (Mobile h-scroll group fix — animation pass 2)
   • Problem: on mobile, .tsh-cases-grid and .tsh-pricing-grid use
     horizontal scroll. Cards beyond the first were off-screen
     horizontally when the section entered viewport, so the
     IntersectionObserver didn't fire for them until the user swiped —
     producing a broken "fade-in-on-swipe" feel, and (on pricing) only
     the Starter card visible until the user scrolled the carousel.
   • Fix:
     - HTML: added [data-reveal-h-scroll] attribute to the two
       horizontal-scroll grid containers (.tsh-cases-grid and
       .tsh-pricing-grid). This is a pure marker — no styling effect.
     - JS scrollReveal(): on mobile (max-width: 760px), any [data-reveal]
       element whose ancestor has [data-reveal-h-scroll] is NOT observed
       individually. Instead the ancestor itself is observed (marked with
       [data-reveal-group-trigger]). When the ancestor enters viewport,
       all [data-reveal] descendants are revealed simultaneously — each
       still respects its own data-reveal-delay so the cascade plays out,
       but it plays out for all cards together rather than one-at-a-time
       on swipe.
     - Cards 2 and 3 finish their fade while off-screen; when the user
       swipes to them they're already at rest. No mid-swipe animation.
   • Desktop behavior unchanged — items animate individually as before
     (no horizontal scroll on desktop, so the original per-item
     observation is correct).
   • Pattern mirrors Stripe / Linear mobile carousels: section reveals
     as a unit when scrolled to, swipes within the section are pure
     pan with no animation surprises.
   ----------------------------------------------------------------------------
   v3.12.5 (Scroll-reveal base system — animation pass 1)
   • New CSS module + JS module + markup attributes:
     - CSS: html.js-reveal [data-reveal] hidden state, .is-revealed target
       state. 600ms / cubic-bezier(0.16, 1, 0.3, 1) / translateY(16px).
       Reduced-motion override snaps to revealed instantly.
     - JS: scrollReveal() IIFE inside main.js outer wrapper. Reads
       data-reveal-delay → CSS custom prop (--reveal-delay).
       IntersectionObserver, threshold 0.15, rootMargin 0px 0px -8% 0px.
       One-shot per element (unobserve on reveal). Reduced motion or
       missing IO support → instant reveal, no observer.
     - HTML: class="js-reveal" hardcoded on <html>. <noscript> override
       in <head> resets visibility for JS-disabled users (defense in
       depth — content is never permanently hidden).
   • Stagger pattern within each section:
     - kicker:    0ms
     - headline:  100ms
     - subhead:   200ms
     - cards/items: 300ms + 80ms per card
     - FAQ items: 200ms + 60ms per item (denser stagger, more items)
   • Sections annotated (10 total):
     - BYOC carrier strip (head + 2 carrier cards + 3 proof pills)
     - 01 Problem (kicker/h2/sub/3 cards)
     - 02 How-it-works (kicker/h2/sub/4 steps)
     - 03 Calculator (kicker/h2/sub/calc-row/bottom CTA/fineprint)
     - 04 Use cases (kicker/h2/sub/3 cards/also-used-for/trust card)
     - 05 Multi-business (kicker/h2/sub/feats list/CTAs/mockup)
     - 06 Why TextScaleHQ (kicker/h2/sub/comparison table)
     - 07 Pricing (kicker/h2/sub/3 plans)
     - 08 FAQ (kicker/h2/8 details items)
     - Footer CTA (whole block)
   • Hero intentionally NOT annotated — above-the-fold on load,
     content must be present on first paint, no flash-to-visible.
   • Header, footer link rows: not annotated (load-state UI / nav).
   • Future-pass candidates (NOT in this version):
     - Per-mockup animations on How-it-works steps (e.g. Step 03
       Import Contacts row-by-row reveal, Step 04 send-queue fill)
     - KPI count-up numbers
     - Carrier card "Live" pulse on Step 02 verify
   ----------------------------------------------------------------------------
   v3.12.4 (Cleanup pass — hero, header, eyebrows, CTAs, chips)
   • Removed "Built for Twilio + Vonage users" badge from hero (was the
     .tsh-eyebrow pill above the headline). Hero now opens cold on H1.
   • Removed the grid background pattern from .tsh-hero::after — kept
     the soft radial glow in ::before. Hero is now plain neutral bg.
   • Header glass effect removed: .tsh-header now solid #fff, no
     backdrop-filter. Reads as a hard surface on scroll instead of
     translucent overlay.
   • Section kicker pills: removed the leading indicator dot
     (.tsh-kicker-dot). Layout now: "01 · THE PROBLEM" — number,
     middle-dot separator, label. Eight kickers updated in markup
     (sections 01–08); CSS rule for .tsh-kicker-dot retained but
     unused (no orphan-class risk; can be GC'd later).
   • Removed trailing "→" arrows from all primary CTAs:
     hero, calculator-bottom, use-cases-bottom, pricing-card, footer.
     Mockup arrows (Send now →, htl-payoff-arrow, in-message →) kept
     — those are content of the illustrated text mockups, not CTAs.
   • "Also used for" chips (.tsh-cases-also-pill):
       - Removed leading blue dot (::before pseudo)
       - Removed :hover state entirely (border/shadow/color)
       - Added cursor: default — chips are decorative, not links
     Eliminates the persistent blue-border state on mobile tap.
   ----------------------------------------------------------------------------
   v3.12 (Ticket — how-it-works 4-step redesign)
   • Dropped "Create your account" as a visible step. Signup is implicit
     (every SaaS landing page has it; using it as Step 01 wastes a screen
     on generic auth UI). Section now opens with a real product step.
   • New 4-step flow: 01 Create your first business → 02 Connect Twilio
     or Vonage → 03 Import your contacts → 04 Send your first blast.
     Steps 02 and 04 (was 02/03) keep their existing high-fidelity
     mockups untouched. Step 04 markup uses .cm-* / .mn-* / .ms-*
     classes from when it was step 03 — class names retained to avoid
     a no-op rename across hundreds of style declarations.
   • Tightened .tsh-step vertical padding 48px → 32px to compensate for
     the extra step. Net section length is roughly the same as v3.11.
   • Mobile timeline: same compact-card pattern, just 4 cards now. Times
     on the meta row: 0:30s · 0:31s · 1:00s · 1:00s ≈ 3min total.
   • REMOVED CSS (orphaned by signup-step deletion):
       - .signup-* (desktop signup card, ~14 rules)
       - .s1-* (mobile signup card, ~13 rules) — name freed, immediately
         repurposed for the new mobile Create Business preview
   • ADDED CSS:
       - .cb-* (desktop Create Business modal — replaces .signup-*)
       - .cb-mw .mw-body override (modal-on-faux-page layout)
       - .ic-* (desktop Import Contacts modal — new step 03)
       - .ic-mw .mw-body override (same modal-overlay pattern)
       - .s1-* (mobile Create Business preview — new under same name)
       - .s3i-* (mobile Import Contacts preview — "i" disambiguates
         from .s3-* mobile campaign which moved to step 04)
   ----------------------------------------------------------------------------
   v3.10 (Ticket 4 — works-with section rebuild)
   • Eyebrow "WORKS WITH" → "BRING YOUR OWN CARRIER" — describes the BYOC
     concept, not the feature label. Added a right-side tagline pill
     "YOUR RATE. YOUR BILL." in brand-2.
   • Each carrier note now ends with a brand-blue ".pay-direct" span:
     "You pay Twilio directly" / "You pay Vonage directly" — puts the
     BYOC mechanic on the carrier cards themselves, not buried in a footer.
   • Replaced the 12px gray ".tsh-carriers-foot" microcopy with a real
     three-pillar BYOC proof strip (.tsh-byoc-strip):
       1. You own the credentials
       2. You pay your carrier directly
       3. Zero per-message markup
     Each pill carries a custom outlined SVG icon in a soft brand-tinted
     tile + a subhead clarifier. The strategic punchline of the section
     is no longer whispered.
   • Added ".tsh-carriers-jump" — small dashed-underline link that points
     down to the calculator section ("↓ See your exact cost in the
     calculator below"), tying the two sections together narratively.
   • Carrier cards bumped: padding 18→22px, radius 14→18px (--radius-lg),
     marks bumped 48→52px with a hairline + --shadow-xs, wordmark 20→22px.
     Subtle box-shadow on hover (--shadow-sm). Section padding +25%.
     Added a faint radial-gradient atmosphere via section ::before.
   • Added shadow scale tokens to :root: --shadow-xs / --shadow-sm.
     Used only by the works-with section in this release; future calc
     and how-it-works passes can adopt them.
   ----------------------------------------------------------------------------
   v3.9 (Ticket 3 hotfix #2 — mobile Twilio color + payoff line trim)
   • Mobile hero chips (.tsh-heroA-chip-mobile) now have brand-color rules
     parallel to desktop. Twilio renders red (#F22F46), Vonage stays black.
     Previously the mobile chip inherited color: var(--ink) and Twilio
     rendered black. Added is-twilio / is-vonage state modifiers on the
     two mobile chip wrappers + matching CSS descendant selectors.
   • Removed "No exceptions." from the how-it-works payoff strip label.
     Was: "From signup to first send. No exceptions."  Now: "From signup
     to first send." — the 00:00 → 04:48 time arrow above does the heavy
     lifting; the trailing tagline was over-explanation.
   ----------------------------------------------------------------------------
   v3.8 (Ticket 3 hotfix — megaphone outline)
   • Megaphone trumpet body changed from filled to outlined for consistency
     with the rest of the page's outline-style icon set (hero sidebar nav,
     custom key icon, etc.). Stroke weight unified at 2px across the whole
     megaphone (was 2px body + 1.6px handle/waves).
   ----------------------------------------------------------------------------
   v3.7 (Ticket 3 — icon system pass)
   • Hero Twilio + Vonage chips: white-tile baseline (matches "works with"
     section). Twilio = red ring with 4 red dots. Vonage = bold black V.
     The brand mark itself carries the identity, not a colored background.
   • Messaging-setup tabs (.ms-tab-icon): same white-tile baseline replaces
     the literal "T"/"V" letters. Now shows real Twilio + Vonage marks.
   • Megaphone redrawn — solid trumpet body + handle/stand + 2 sound-wave
     arcs (second arc at 60% opacity for depth). Replaces the old flat
     4-point quadrilateral. Applied at hero sidebar nav + hero camp icon.
   • 3 paper-plane "Campaigns" nav icons converted to the new megaphone
     (messaging-setup mockup, campaigns mockup, agency mockup) — every
     "Campaigns" reference on the page now uses the same hand-drawn icon.
   ----------------------------------------------------------------------------
   v3.6 (Ticket 2 — how-it-works mobile redesign)
   • Replaced .tsh-how-mobile horizontal carousel with vertical timeline:
     numbered nodes (44px circles) + dashed connector + 3 stacked cards
   • Each step now contains a mini browser-frame mockup of the actual product
     (signup form / messaging setup / campaign with iMessage bubble preview)
   • Step 02 uses new custom SVG key icon for "Provider Credentials" header
     and white-tile Twilio + Vonage brand marks (matches "works with" baseline)
   • Step 03 in PRE-SEND state: "Send now" CTA, no "Sent · delivered" success
     state — matches live UI mid-action
   • New payoff strip at bottom: "00:00 → 04:48 · From signup to first send"
     (dark navy gradient — mobile only, no desktop equivalent)
   • Removed orphan .scroll-dots / .scroll-dot CSS (only used by old carousel)
   ----------------------------------------------------------------------------
   v3.5 (Ticket 1 — copy + structure pass)
   • Switching strip label: "Designed for teams switching from"
                           → "For teams outgrowing bundled SMS platforms"
   • Use-cases subheadline replaced (focus on promos/reminders/reactivation)
   • Use-cases reduced 6 cards → 3: Restaurants, Salons, Retail
     (Real estate, Fitness, Agencies moved to "Also used for" pill row)
   • New "Also used for" pill row added (.tsh-cases-also block + CSS)
   • Bottom card revamped: title "One dashboard. Every customer list.",
     pills replaced with use-case tags, green-check icon retained
   • Notification app icons replaced with iOS Messages green-bubble
     across all three use-case cards
   • Acme → Riverside Coffee everywhere (use-case card + hero phone +
     hero campaign card sub-text). Single demo brand across the page.
   ========================================================================= */

:root {
  --brand-1: #6CB1FF;
  --brand-2: #0b74ff;
  --brand-3: #0856CC;
  --brand-grad: #0b74ff;   /* v3.21.1: flat. Name kept so every rule using it turns solid together. */

  --ink:   #0f172a;
  --ink-2: #334155;
  --ink-3: #64748b;
  --ink-4: #94a3b8;

  --bg:    #ffffff;
  --bg-2:  #f8fafc;
  --bg-3:  #f1f5f9;
  --line:  #e2e8f0;
  --line-2:#cbd5e1;

  --green: #10b981;
  --orange-1: #fb923c;
  --orange-2: #ef4444;

  --radius:    12px;
  --radius-lg: 18px;
  --radius-xl: 24px;
  --btn-radius: 8px;

  /* shadow scale (added v3.10 for works-with cards) */
  --shadow-xs: 0 1px 0 rgba(15,23,42,0.025);
  --shadow-sm: 0 1px 0 rgba(15,23,42,0.03), 0 1px 2px rgba(15,23,42,0.04);
}

/* ----- Base ---------------------------------------------------------------- */
* { box-sizing: border-box; margin: 0; padding: 0; }
/* v3.18.4: scroll-padding-top clears the ~70px sticky .tsh-header so
   in-page anchors don't park section headlines behind it. */
html { scroll-behavior: smooth; scroll-padding-top: 84px; -webkit-text-size-adjust: 100%; }
body {
  font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  font-feature-settings: 'cv11', 'ss01';
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background: var(--bg);
  color: var(--ink);
  line-height: 1.5;
  overflow-x: hidden;
}
button { font-family: inherit; cursor: pointer; border: 0; background: none; color: inherit; }
a { color: inherit; text-decoration: none; }
img, svg { display: block; max-width: 100%; height: auto; }

.tsh-skip-link {
  position: absolute; left: -9999px; top: 8px;
  background: var(--ink); color: #fff; padding: 8px 14px;
  border-radius: 8px; font-weight: 600; z-index: 999;
}
.tsh-skip-link:focus { left: 8px; }

.tsh-grad {
  background: var(--brand-grad);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  /* explicit fill-color for older Safari */
  -webkit-text-fill-color: transparent;
  /* v3.14.7: text-shadow trick painted on top of the gradient (CSS paint
     order: bg → shadow → text), making everything look solid blue.
     Returning to display: inline-block — the actual fix for the iOS Safari
     descender clipping bug, since inline-block creates a fresh paint
     context that respects the full padding box.
     Orphan-period side effect handled in markup: trailing periods are now
     INSIDE the gradient span so they stay attached to the last word.
     max-width: 100% lets long phrases still wrap internally when needed. */
  display: inline-block;
  max-width: 100%;
  vertical-align: baseline;
  line-height: 1.25;
  padding-bottom: 0.25em;
  margin-bottom: -0.15em;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ============================================================================
   SCROLL REVEAL (v3.12.5)
   - Gated by html.js-reveal so JS-disabled users see all content immediately
   - Fade + 16px translateY, 600ms, ease-out-quint
   - Stagger via inline --reveal-delay (set by JS from data-reveal-delay)
   - One-shot: JS unobserves after first reveal
   ========================================================================= */
html.js-reveal [data-reveal] {
  opacity: 0;
  transform: translateY(16px);
  transition:
    opacity 600ms cubic-bezier(0.16, 1, 0.3, 1),
    transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
  transition-delay: var(--reveal-delay, 0ms);
}
/* Transient compositor-layer hint: JS adds .is-revealing right before
   the animation starts and removes it on transitionend. This keeps the
   GPU layer count low during scroll — only the few elements actively
   animating get promoted. Avoids the mobile scroll-jank that always-on
   will-change causes when applied to many elements. */
html.js-reveal [data-reveal].is-revealing {
  will-change: opacity, transform;
}
html.js-reveal [data-reveal].is-revealed {
  opacity: 1;
  transform: translateY(0);
}
/* Reduced motion: snap to final state, no transition */
@media (prefers-reduced-motion: reduce) {
  html.js-reveal [data-reveal] {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* ============================================================================
   STEP 03 IMPORT CONTACTS ANIMATION (v3.12.8, simplified in v3.18)
   - Triggers when .ic-modal[data-step-import="desktop"] enters viewport.
   - Sequence: drop accepts → selects fill → 2 checkboxes check → count
     badge fades in + ticks 0 → 1,247.
   - v3.18: the .s3i-* mobile mockup tree was retired along with the
     mobile timeline, so the .s3i-cell-select, .s3i-drop, .s3i-btn-count
     compound selectors were dropped. The desktop sequence now also
     plays inside the mobile bleed-and-fade pattern (steps 2 & 4
     framing) since both viewports share the same DOM.
   ========================================================================= */

/* ============================================================================
   HEADER
   ========================================================================= */
.tsh-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: #fff;
  border-bottom: 1px solid var(--line);
}
.tsh-header-inner {
  max-width: 1240px;
  margin: 0 auto;
  padding: 14px clamp(1rem, 4vw, 2rem);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}
.tsh-brand { display: flex; align-items: center; gap: 10px; }
.tsh-brand-mark { flex-shrink: 0; }
.tsh-brand-text {
  font-weight: 800; font-size: 17px;
  letter-spacing: -0.02em; color: var(--ink);
}

/* ---- 2.4.0: primary nav with Features / Resources menus (inc/nav.php) ---- */
.tsh-ico { width: 18px; height: 18px; flex-shrink: 0; }
.tsh-ico-sm { width: 15px; height: 15px; }
.tsh-nav { display: none; align-items: center; gap: 2px; margin-left: 18px; margin-right: auto; }
@media (min-width: 980px) { .tsh-nav { display: flex; } }
.tsh-nav-tab {
  display: inline-flex; align-items: center; gap: 5px;
  font-family: inherit; font-weight: 500; font-size: 14px; line-height: 1;
  color: var(--ink-2); background: none; border: 0; cursor: pointer;
  padding: 10px 12px; border-radius: 8px;
  transition: color 150ms, background-color 150ms;
}
.tsh-nav-tab:hover, .tsh-nav-tab[aria-expanded="true"] { color: var(--ink); background: var(--bg-3); }
.tsh-nav-tab.is-current { color: var(--ink); font-weight: 600; }
.tsh-nav-chev { width: 14px; height: 14px; transition: transform 200ms; }
.tsh-nav-tab[aria-expanded="true"] .tsh-nav-chev { transform: rotate(180deg); }

.tsh-header-actions { display: flex; align-items: center; gap: 10px; }

.tsh-login {
  display: none;
  font-size: 14px; font-weight: 600;
  color: var(--ink-2);
  padding: 8px 14px;
  border-radius: var(--btn-radius);
  transition: color 150ms;
}
.tsh-login:hover { color: var(--ink); }
@media (min-width: 700px) { .tsh-login { display: inline-block; } }

/* 2.4.0: no glow; the colour changes on hover and press instead */
.tsh-cta-pill {
  background: var(--brand-2);
  color: white;
  font-size: 14px; font-weight: 600;
  padding: 10px 18px;
  border-radius: var(--btn-radius);
  transition: background-color 150ms;
  white-space: nowrap;
}
.tsh-cta-pill:hover { background: var(--brand-3); color: white; }
.tsh-cta-pill:active { background: #0747A6; }

.tsh-hdr-search {
  display: none; align-items: center; gap: 8px;
  height: 36px; padding: 0 10px 0 12px; min-width: 200px;
  border: 1px solid var(--line); border-radius: 8px; background: var(--bg-2);
  color: var(--ink-3); font-family: inherit; font-weight: 500; font-size: 13.5px; line-height: 1; cursor: pointer;
}
.tsh-hdr-search:hover { border-color: var(--line-2); color: var(--ink-2); }
@media (min-width: 1100px) { .tsh-hdr-search { display: inline-flex; } }
.tsh-kbd {
  margin-left: auto;
  font-family: inherit; font-weight: 600; font-size: 11px; line-height: 1.4;
  color: var(--ink-3); background: var(--bg);
  border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px; padding: 1px 6px;
}

.tsh-burger {
  display: flex;
  flex-direction: column;
  gap: 5px;
  padding: 10px;
  margin-right: -10px;
}
.tsh-burger span { width: 20px; height: 2px; background: var(--ink); border-radius: 1px; transition: transform 200ms, opacity 200ms; }
@media (min-width: 980px) { .tsh-burger { display: none; } }
/* 2.5.5: the header fits 360px phones on every page. home.css v3.22.0 had this
   for the front page only, so other pages scrolled sideways 5px at 360. */
@media (max-width: 374px) {
  .tsh-header-actions { gap: 6px; }
  .tsh-cta-pill { padding: 9px 13px; font-size: 13.5px; }
}

.tsh-burger.is-open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.tsh-burger.is-open span:nth-child(2) { opacity: 0; }
.tsh-burger.is-open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }

/* Menu panels: drop from the header's bottom edge */
.tsh-panel {
  position: absolute; left: 0; right: 0; top: 100%;
  background: #fff; border-bottom: 1px solid var(--line);
  box-shadow: 0 24px 60px -18px rgba(15,23,42,.28), 0 2px 6px rgba(15,23,42,.06);
  opacity: 0; visibility: hidden; transform: translateY(-6px);
  transition: opacity 160ms, transform 160ms, visibility 0s 160ms;
}
.tsh-panel.is-open { opacity: 1; visibility: visible; transform: none; transition: opacity 160ms, transform 160ms; }
@media (max-width: 979px) { .tsh-panel { display: none; } }
.tsh-panel-in { max-width: 1240px; margin: 0 auto; padding: 26px clamp(1rem, 4vw, 2rem) 0; }
.tsh-mega { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 300px; gap: 10px 28px; }
.tsh-res { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) 300px; gap: 10px 28px; }
.tsh-mega-h { margin: 0 0 8px 12px; font-size: 11.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-4); }
.tsh-mi { display: flex; gap: 14px; align-items: flex-start; padding: 10px 12px; border-radius: 10px; transition: background-color 120ms; }
.tsh-mi:hover, .tsh-mi:focus-visible { background: var(--bg-2); }
.tsh-mi:hover .tsh-mi-t { color: var(--brand-3); }
.tsh-mi-ic { color: var(--brand-2); display: flex; margin-top: 1px; }
.tsh-mi-ic .tsh-ico { width: 22px; height: 22px; }
.tsh-mi-t { display: block; font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.3; }
.tsh-mi-d { display: block; font-size: 13px; color: var(--ink-3); line-height: 1.4; margin-top: 2px; }
.tsh-promo { display: flex; flex-direction: column; gap: 10px; border-radius: 14px; padding: 20px; background: var(--bg-2); border: 1px solid var(--line); }
.tsh-promo-eb { font-size: 11.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--brand-3); }
.tsh-promo-h { margin: 0; font-size: 17px; font-weight: 700; letter-spacing: -.01em; line-height: 1.3; color: var(--ink); }
.tsh-promo-p { margin: 0; font-size: 13.5px; color: var(--ink-2); line-height: 1.5; }
.tsh-promo-rc { background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font-size: 12.5px; font-variant-numeric: tabular-nums; }
.tsh-promo-rc div { display: flex; justify-content: space-between; padding: 3px 0; color: var(--ink-2); }
.tsh-promo-rc .is-tot { border-top: 1px dashed var(--line-2); margin-top: 4px; padding-top: 7px; color: var(--ink); font-weight: 700; }
.tsh-promo-fine { margin: 0; font-size: 11.5px; color: var(--ink-3); line-height: 1.45; }
.tsh-promo-lnk { margin-top: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 13.5px; font-weight: 600; color: var(--brand-2); }
.tsh-promo-lnk:hover { color: var(--brand-3); }
.tsh-panel-foot { display: flex; flex-wrap: wrap; gap: 8px 26px; align-items: center; margin-top: 20px; padding: 14px 12px; border-top: 1px solid var(--line); font-size: 13.5px; }
.tsh-panel-foot a { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; color: var(--ink-2); }
.tsh-panel-foot a:hover { color: var(--brand-2); }
.tsh-panel-note { margin-left: auto; color: var(--ink-3); font-size: 13px; }
.tsh-res-search {
  display: flex; align-items: center; gap: 10px; width: 100%;
  margin: 4px 0 8px; padding: 11px 12px;
  border: 1px solid var(--line); border-radius: 10px; background: var(--bg-2);
  color: var(--ink-3); font-family: inherit; font-weight: 500; font-size: 14px; line-height: 1.2; text-align: left; cursor: pointer;
}
.tsh-res-search:hover { border-color: var(--line-2); color: var(--ink-2); }

/* Phone menu: full-height sheet under the header, accordions */
.tsh-mobile-menu {
  position: fixed; left: 0; right: 0; bottom: 0; top: var(--tsh-hdr-h, 64px);
  z-index: 99; overflow-y: auto; background: #fff;
  display: flex; flex-direction: column;
  padding: 4px clamp(1rem, 4vw, 2rem) env(safe-area-inset-bottom, 0px);
  border-top: 1px solid var(--line);
}
.tsh-mobile-menu[hidden] { display: none; }
.tsh-acc { border-bottom: 1px solid var(--bg-3); }
.tsh-acc-btn {
  display: flex; width: 100%; align-items: center; justify-content: space-between;
  padding: 16px 4px; background: none; border: 0; cursor: pointer;
  font-family: inherit; font-weight: 600; font-size: 17px; line-height: 1.2; color: var(--ink); text-align: left;
}
.tsh-acc-btn .tsh-nav-chev { width: 18px; height: 18px; color: var(--ink-3); }
.tsh-acc-btn[aria-expanded="true"] .tsh-nav-chev { transform: rotate(180deg); }
.tsh-acc-body { padding: 0 0 12px; }
.tsh-acc-body[hidden] { display: none; }
.tsh-acc-body .tsh-mega-h { margin: 10px 4px 2px; }
.tsh-acc-body .tsh-mi { padding: 9px 4px; align-items: center; }
.tsh-acc-all { display: block; padding: 10px 4px 2px; font-size: 14px; font-weight: 600; color: var(--brand-2); }
.tsh-mobile-foot { margin-top: auto; display: grid; gap: 10px; padding: 18px 0 22px; }
.tsh-mobile-foot a { display: flex; justify-content: center; padding: 13px; font-size: 15px; }
.tsh-mobile-ghost { border: 1px solid var(--line-2); border-radius: var(--btn-radius); font-weight: 600; color: var(--ink); }
@media (min-width: 980px) { .tsh-mobile-menu { display: none !important; } }
body.tsh-menu-open { overflow: hidden; }

/* ============================================================================
   FOOTER
   ========================================================================= */
.tsh-footer {
  background: white;
  position: relative;
}
.tsh-footer-inner {
  max-width: 1240px;
  margin: 0 auto;
  /* v3.21.0: top raised from clamp(2.5rem,5vw,3.5rem). The brand row used to
     buy this air; with it gone the column headings crowded the CTA band edge. */
  padding: clamp(3rem, 5.5vw, 4rem) clamp(1.25rem, 4vw, 2rem) clamp(1.5rem, 3vw, 2rem);
}

/* Final CTA — full-bleed band (v3.19.3, was a contained card)
   Sits OUTSIDE .tsh-footer-inner so it can span the viewport, matching the
   way every other section on the page owns its own background. */
.tsh-footer-cta {
  position: relative;
  overflow: hidden;
  color: white;
  text-align: center;
  padding: clamp(3.25rem, 8vw, 5.5rem) clamp(1.25rem, 4vw, 2rem);
  /* 2.4.0: solid, no gradient (was 135deg #6CB1FF → #0b74ff → #0856CC) */
  background: var(--brand-2);
}
/* light source anchored top-centre, same convention as the section glows */
.tsh-footer-cta::before {
  content: ''; position: absolute;
  left: 50%; top: -110%; transform: translateX(-50%);
  width: 1100px; height: 230%;
  background: radial-gradient(ellipse at center, rgba(255,255,255,0.24) 0%, transparent 60%);
  pointer-events: none; z-index: 0;
}
/* dots masked so they fade out, rather than tiling flat across the field */
.tsh-footer-cta-dots {
  position: absolute; inset: 0;
  pointer-events: none; z-index: 1;
  background-image: radial-gradient(circle, rgba(255,255,255,0.15) 1px, transparent 1px);
  background-size: 26px 26px;
  -webkit-mask-image: radial-gradient(ellipse 70% 90% at 50% 0%, #000 0%, transparent 72%);
          mask-image: radial-gradient(ellipse 70% 90% at 50% 0%, #000 0%, transparent 72%);
}
.tsh-footer-cta-inner {
  position: relative; z-index: 2;
  max-width: 720px; margin: 0 auto;
}
.tsh-footer-cta-title {
  font-size: clamp(1.85rem, 4.4vw, 3rem);
  font-weight: 800; letter-spacing: -0.03em;
  line-height: 1.06; margin-bottom: 14px;
}
/* v3.19.3: centred, not left-aligned. Supersedes the v3.18.5 note that
   this subhead was already flush left; it now sits in a centred band. */
.tsh-footer-cta-sub {
  font-size: clamp(1rem, 1.35vw, 1.1rem);
  color: rgba(255,255,255,0.88);
  line-height: 1.6;
  max-width: 46ch;
  margin: 0 auto 26px;
}
.tsh-footer-cta-actions {
  display: flex; flex-wrap: wrap;
  justify-content: center; gap: 12px;
}
.tsh-footer-cta-solid {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  background: white; color: var(--brand-3);
  font-size: 16px; font-weight: 700;
  padding: 15px 28px; border-radius: var(--btn-radius);
  box-shadow: 0 10px 26px -8px rgba(3, 32, 92, 0.45);
  transition: transform 150ms, box-shadow 200ms;
}
.tsh-footer-cta-solid svg { width: 16px; height: 16px; }
.tsh-footer-cta-solid:hover { transform: translateY(-2px); box-shadow: 0 16px 34px -8px rgba(3, 32, 92, 0.5); }
.tsh-footer-cta-solid:active { transform: translateY(0); }
.tsh-footer-cta-ghost {
  display: inline-flex; align-items: center; justify-content: center;
  background: rgba(255,255,255,0.10); color: white;
  font-size: 16px; font-weight: 600;
  padding: 15px 26px; border-radius: var(--btn-radius);
  border: 1px solid rgba(255,255,255,0.38);
  transition: background 150ms, border-color 150ms;
}
.tsh-footer-cta-ghost:hover { background: rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.7); }
.tsh-footer-cta-fine {
  margin-top: 20px;
  font-size: 12.5px;
  color: rgba(255,255,255,0.62);
}
/* a lone button floating in a blue field reads as an accident on a phone */
@media (max-width: 640px) {
  .tsh-footer-cta-actions { flex-direction: column; }
  .tsh-footer-cta-solid,
  .tsh-footer-cta-ghost { width: 100%; }
}

/* Footer directory (v3.21.0)
   Six columns: 2 up on phones, 3 up at 700px, all six from 1120px. The
   1120px step is where six 13px link columns stop wrapping their labels
   inside the 1240px inner. */
.tsh-footer-cols {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 30px 22px;
  padding-bottom: 32px;
  border-bottom: 1px solid var(--line);
}
@media (min-width: 700px) {
  .tsh-footer-cols { grid-template-columns: repeat(3, 1fr); gap: 34px 24px; }
}
@media (min-width: 1120px) {
  .tsh-footer-cols { grid-template-columns: repeat(6, 1fr); gap: 0 20px; }
}
.tsh-footer-col-h {
  font-size: 11px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--ink-4);
  margin-bottom: 14px;
}
.tsh-footer-nav-list {
  list-style: none;
  display: flex; flex-direction: column;
  gap: 10px;
}
.tsh-footer-nav-list a {
  font-size: 13px; font-weight: 500;
  color: var(--ink-2);
  line-height: 1.35;
  display: inline-flex; align-items: center; gap: 6px;
  transition: color 150ms;
}
.tsh-footer-nav-list a:hover { color: var(--brand-2); }

/* Bottom bar — carries the socials since v3.21.0 */
.tsh-footer-bottom {
  padding-top: 24px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 16px 24px;
  font-size: 12px;
  color: var(--ink-4);
}
.tsh-footer-bottom-l p + p { margin-top: 5px; }
.tsh-footer-bottom-r { display: flex; align-items: center; }
.tsh-footer-social { display: flex; gap: 8px; }
.tsh-footer-social a {
  display: inline-flex; align-items: center; justify-content: center;
  width: 32px; height: 32px;
  border-radius: 8px;
  border: 1px solid var(--line);
  color: var(--ink-3);
  background: white;
  transition: color 150ms, border-color 150ms, transform 150ms;
}
.tsh-footer-social a:hover {
  color: var(--brand-2);
  border-color: var(--brand-1);
  transform: translateY(-2px);
}
.tsh-footer-social svg { width: 15px; height: 15px; }

/* ============================================================================
   BLOG / PAGE / 404
   ========================================================================= */
.tsh-page {
  padding: clamp(3rem, 6vw, 4.5rem) clamp(1.25rem, 4vw, 2rem);
  background: white;
}
.tsh-page-inner { max-width: 760px; margin: 0 auto; }
.tsh-article-head { margin-bottom: 28px; }
.tsh-article-eyebrow {
  font-size: 12px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--brand-2); margin-bottom: 10px;
}
.tsh-article-title {
  font-size: clamp(2rem, 5vw, 3.25rem);
  font-weight: 800; letter-spacing: -0.03em;
  line-height: 1.08;
  color: var(--ink);
}
.tsh-article-meta {
  font-size: 14px; color: var(--ink-4);
  margin-top: 8px;
}
.tsh-article-thumb { margin: 32px 0; border-radius: 16px; overflow: hidden; }
.tsh-article-thumb img { width: 100%; height: auto; display: block; }
.tsh-article-body { font-size: 17px; color: var(--ink-2); line-height: 1.7; }
.tsh-article-body p { margin-bottom: 20px; }
.tsh-article-body h2 {
  font-size: 26px; font-weight: 700;
  margin: 40px 0 14px; color: var(--ink);
  letter-spacing: -0.02em;
}
.tsh-article-body h3 {
  font-size: 20px; font-weight: 700;
  margin: 28px 0 10px; color: var(--ink);
}
.tsh-article-body a { color: var(--brand-2); text-decoration: underline; }
.tsh-article-body blockquote {
  border-left: 4px solid var(--brand-2);
  padding-left: 20px;
  margin: 24px 0;
  color: var(--ink-3);
  font-style: italic;
}
.tsh-article-body code {
  background: var(--bg-2);
  padding: 2px 6px;
  border-radius: 4px;
  font-size: 0.9em;
}
.tsh-article-nav {
  display: flex; justify-content: space-between;
  gap: 16px;
  margin-top: 48px;
  padding-top: 28px;
  border-top: 1px solid var(--line);
  font-size: 14px; font-weight: 600;
}
.tsh-article-nav a { color: var(--brand-2); }
.tsh-post-list {
  display: flex;
  flex-direction: column;
  gap: 28px;
}
.tsh-post-card {
  padding: 24px;
  border: 1px solid var(--line);
  border-radius: 16px;
  background: white;
  transition: border-color 200ms, transform 150ms;
}
.tsh-post-card:hover { border-color: var(--line-2); }
.tsh-post-date {
  font-size: 12px;
  color: var(--ink-4);
  font-weight: 600;
  margin-bottom: 8px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.tsh-post-title { font-size: 22px; font-weight: 700; margin-bottom: 10px; letter-spacing: -0.02em; }
.tsh-post-title a { color: var(--ink); }
.tsh-post-title a:hover { color: var(--brand-2); }
.tsh-post-excerpt { font-size: 15px; color: var(--ink-3); line-height: 1.6; margin-bottom: 12px; }
.tsh-post-more { font-size: 14px; font-weight: 600; color: var(--brand-2); }
.tsh-post-more:hover { text-decoration: underline; }

/* 404 */
.tsh-404 {
  padding: clamp(4rem, 10vw, 7rem) clamp(1.25rem, 4vw, 2rem);
  text-align: center;
  background: var(--bg-2);
  min-height: 60vh;
  display: flex; align-items: center; justify-content: center;
}
.tsh-404-inner { max-width: 560px; }
.tsh-404-code {
  font-size: clamp(5rem, 15vw, 9rem);
  font-weight: 900;
  letter-spacing: -0.06em;
  line-height: 1;
  background: var(--brand-grad);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  margin-bottom: 16px;
}
.tsh-404-title {
  font-size: clamp(1.75rem, 4vw, 2.5rem);
  font-weight: 800;
  letter-spacing: -0.03em;
  margin-bottom: 12px;
}
.tsh-404-sub {
  font-size: 16px;
  color: var(--ink-3);
  margin-bottom: 32px;
}
/* v3.24.1: "Back to home". .tsh-btn-primary lives in the hero and calculator
   blocks, which the theme ships in home.css (front page only), so the 404
   showed a plain link. Scoped to .tsh-404 so the landing page is untouched;
   values are what the landing page's two .tsh-btn-primary rules resolve to. */
.tsh-404 .tsh-btn-primary {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  background: var(--brand-2);
  color: white;
  padding: 16px 32px;
  border-radius: 10px;
  font-weight: 700; font-size: 16px;
  text-decoration: none; border: none;
  cursor: pointer;
  box-shadow: 0 1px 0 rgba(15,23,42,0.04), 0 8px 20px -8px rgba(11,92,255,0.5);
  transition: transform 120ms;
}
.tsh-404 .tsh-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 12px 28px -4px rgba(11, 92, 255, 0.55); }
.tsh-404 .tsh-btn-primary:active { transform: translateY(0); }

/* ============================================================================
   LEGAL (theme 2.3.0) — page-legal.php: /privacy/, /terms/ and any page that
   picks the Legal Page template. The words are the page's own content; this
   block styles what the editor produces (headings, paragraphs, lists, the
   table block, and the group with class tsh-legal-short). Uses the DOCS
   language (sticky list on the left, blue current item) on the site's tokens.
   ========================================================================= */
.tsh-legal { background: white; }
.tsh-legal-band {
  background: var(--bg-2);
  border-bottom: 1px solid var(--line);
  padding: clamp(2.5rem, 6vw, 4rem) clamp(1.25rem, 4vw, 2rem) clamp(2rem, 4vw, 2.75rem);
}
.tsh-legal-band-inner { max-width: 1120px; margin: 0 auto; }
.tsh-legal-eyebrow {
  font-size: 12px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--brand-2); margin-bottom: 10px;
}
.tsh-legal-title {
  font-size: clamp(2rem, 5vw, 3rem);
  font-weight: 800; letter-spacing: -0.03em; line-height: 1.08;
  color: var(--ink);
}
.tsh-legal-meta { font-size: 14px; color: var(--ink-3); margin-top: 10px; }
.tsh-legal-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px; }
.tsh-legal-pills a {
  display: inline-flex; align-items: center;
  padding: 8px 16px; border-radius: 999px;
  border: 1px solid var(--line-2); background: white;
  font-size: 14px; font-weight: 600; color: var(--ink-2);
  transition: border-color 130ms, color 130ms, background 130ms;
}
.tsh-legal-pills a:hover { border-color: var(--brand-1); color: var(--brand-3); }
.tsh-legal-pills a.is-current { background: var(--brand-2); border-color: var(--brand-2); color: white; }

.tsh-legal-grid {
  box-sizing: content-box; /* 1120px of content, padding outside it: lines up with the band */
  max-width: 1120px; margin: 0 auto;
  padding: clamp(1.75rem, 4vw, 3rem) clamp(1.25rem, 4vw, 2rem) clamp(3rem, 7vw, 5rem);
}
@media (min-width: 1024px) {
  .tsh-legal-grid { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: 56px; align-items: start; }
  .tsh-legal-grid.is-single { grid-template-columns: minmax(0, 1fr); }
}

/* "On this page" (desktop) */
.tsh-legal-toc { display: none; }
@media (min-width: 1024px) {
  .tsh-legal-toc {
    display: block; position: sticky; top: 96px;
    max-height: calc(100vh - 120px); overflow-y: auto;
    padding: 4px 0 8px;
  }
}
.tsh-legal-toc-h {
  font-size: 11px; font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.09em; color: var(--ink-4); margin-bottom: 10px;
}
.tsh-legal-toc-list { list-style: none; border-left: 1px solid var(--line); }
.tsh-legal-toc-list a {
  display: block; margin-left: -1px;
  padding: 6px 10px 6px 14px; border-left: 2px solid transparent;
  font-size: 13.5px; font-weight: 500; line-height: 1.35; color: var(--ink-3);
  transition: color 130ms, border-color 130ms, background 130ms;
}
.tsh-legal-toc-list a:hover { color: var(--ink); }
.tsh-legal-toc-list a.is-current {
  color: var(--brand-3); font-weight: 600;
  border-left-color: var(--brand-2); background: rgba(11,116,255,0.06);
}

/* "Jump to" (below 1024px) */
.tsh-legal-jump { margin-bottom: 26px; }
@media (min-width: 1024px) { .tsh-legal-jump { display: none; } }
.tsh-legal-jump label {
  display: block; font-size: 11px; font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.09em; color: var(--ink-4); margin-bottom: 8px;
}
.tsh-legal-jump select {
  width: 100%; font: 500 15px/1.2 'Inter', sans-serif; color: var(--ink-2);
  padding: 11px 12px; border: 1px solid var(--line-2); border-radius: var(--btn-radius);
  background: white;
}

/* The text */
.tsh-legal-main { min-width: 0; }
.tsh-legal-body {
  max-width: 72ch; font-size: 16.5px; line-height: 1.7; color: var(--ink-2);
  counter-reset: tsh-legal;
  overflow-wrap: break-word;
}
.tsh-legal-body > * + * { margin-top: 0; }
.tsh-legal-body p { margin: 0 0 16px; }
.tsh-legal-body strong { color: var(--ink); font-weight: 600; }
.tsh-legal-body a { color: var(--brand-2); text-decoration: underline; text-underline-offset: 2px; }
.tsh-legal-body a:hover { color: var(--brand-3); }
.tsh-legal-body h2 {
  font-size: 22px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.3;
  color: var(--ink); margin: 44px 0 12px;
  scroll-margin-top: 12px; /* + html scroll-padding-top 84px = clears the header */
  counter-increment: tsh-legal;
}
.tsh-legal-body h2::before {
  content: counter(tsh-legal) ".";
  color: var(--brand-2); margin-right: 10px; font-variant-numeric: tabular-nums;
}
.tsh-legal-body h3 { font-size: 17px; font-weight: 700; color: var(--ink); margin: 26px 0 8px; scroll-margin-top: 12px; /* + html scroll-padding-top 84px = clears the header */ }
.tsh-legal-body ul, .tsh-legal-body ol { margin: 0 0 18px; padding-left: 1.3em; }
.tsh-legal-body li { margin: 6px 0; padding-left: 2px; }
.tsh-legal-body li::marker { color: var(--ink-4); }
.tsh-legal-body li > ul, .tsh-legal-body li > ol { margin: 6px 0 4px; }
.tsh-legal-body figure { margin: 4px 0 22px; }
.tsh-legal-body .wp-block-table {
  border: 1px solid var(--line); border-radius: var(--radius);
  overflow-x: auto; -webkit-overflow-scrolling: touch;
}
.tsh-legal-body table { width: 100%; border-collapse: collapse; font-size: 15px; line-height: 1.5; }
.tsh-legal-body th, .tsh-legal-body td {
  text-align: left; vertical-align: top;
  padding: 11px 16px; border: 0; border-bottom: 1px solid var(--line);
}
.tsh-legal-body thead th {
  background: var(--bg-2); color: var(--ink); font-weight: 600; font-size: 13px;
  text-transform: uppercase; letter-spacing: 0.04em;
}
.tsh-legal-body tbody tr:last-child td { border-bottom: 0; }
.tsh-legal-body td:first-child { color: var(--ink); font-weight: 500; width: 46%; }
.tsh-legal-body .wp-block-table thead { border-bottom: 0; }

/* "The short version" */
.tsh-legal-body .tsh-legal-short {
  background: rgba(11,116,255,0.05);
  border: 1px solid rgba(11,116,255,0.18);
  border-radius: var(--radius-lg);
  padding: 22px 24px 8px;
  margin: 0 0 12px;
}
.tsh-legal-body .tsh-legal-short h2 {
  font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--brand-3); margin: 0 0 8px;
  counter-increment: none;
}
.tsh-legal-body .tsh-legal-short h2::before { content: none; }
.tsh-legal-body .tsh-legal-short ul { list-style: none; padding-left: 0; }
.tsh-legal-body .tsh-legal-short li { position: relative; padding-left: 28px; margin: 10px 0; color: var(--ink); }
.tsh-legal-body .tsh-legal-short li::before {
  content: ""; position: absolute; left: 0; top: 4px;
  width: 18px; height: 18px; border-radius: 50%;
  background: var(--brand-2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") center / 11px no-repeat;
}

@media (max-width: 640px) {
  .tsh-legal-body { font-size: 16px; }
  .tsh-legal-body h2 { font-size: 20px; margin-top: 36px; }
  .tsh-legal-body .tsh-legal-short { padding: 18px 18px 6px; }
  .tsh-legal-body th, .tsh-legal-body td { padding: 10px 12px; }
  .tsh-legal-body td:first-child { width: 36%; }
}

@media print {
  body:has(.tsh-legal) .tsh-header, body:has(.tsh-legal) .tsh-footer, body:has(.tsh-legal) .tsh-skip-link,
  .tsh-legal-toc, .tsh-legal-jump, .tsh-legal-pills { display: none !important; }
  .tsh-legal-band { background: none; border: 0; padding: 0 0 12pt; }
  .tsh-legal-grid { display: block; padding: 0; max-width: none; }
  .tsh-legal-body { max-width: none; font-size: 10.5pt; }
  .tsh-legal-body h2 { break-after: avoid; margin-top: 18pt; }
  .tsh-legal-body tr, .tsh-legal-body li { break-inside: avoid; }
  .tsh-legal-body .tsh-legal-short { background: none; }
  .tsh-legal-body .tsh-legal-short li::before { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
}

/* ==========================================================================
   2.4.0 — SOLID TRIAL BAND, NO BUTTON GLOW
   ========================================================================== */
.tsh-footer-cta::before,
.tsh-footer-cta-dots { display: none; }
.tsh-footer-cta-solid { box-shadow: none; transition: background-color 150ms; }
.tsh-footer-cta-solid:hover { transform: none; box-shadow: none; background: #e8f1ff; }
.tsh-footer-cta-solid:active { transform: none; background: #d4e5ff; }
.tsh-404 .tsh-btn-primary { box-shadow: none; transition: background-color 150ms; }
.tsh-404 .tsh-btn-primary:hover { transform: none; box-shadow: none; background: var(--brand-3); }
.tsh-404 .tsh-btn-primary:active { background: #0747A6; }

/* ==========================================================================
   2.4.0 — DOCS SEARCH WINDOW (inc/search.php, assets/js/search.js)
   Also defines .screen-reader-text, which docs labels used but nothing styled.
   ========================================================================== */
.screen-reader-text { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
.tsh-sx-ov { position: fixed; inset: 0; z-index: 300; background: rgba(15,23,42,.42); display: flex; align-items: flex-start; justify-content: center; padding: calc(10vh + env(safe-area-inset-top, 0px)) 16px 16px; }
.tsh-sx-ov[hidden] { display: none; }
body.tsh-sx-open { overflow: hidden; }
.tsh-sx-modal { width: 100%; max-width: 640px; background: #fff; border-radius: 16px; box-shadow: 0 40px 90px -20px rgba(15,23,42,.5); overflow: hidden; display: flex; flex-direction: column; max-height: 76vh; }
.tsh-sx-head { display: flex; align-items: center; gap: 10px; padding: 0 14px; border-bottom: 1px solid var(--line); color: var(--ink-3); }
.tsh-sx-head input { flex: 1; min-width: 0; height: 56px; border: 0; outline: none; font-family: inherit; font-size: 16px; font-weight: 500; color: var(--ink); background: transparent; }
.tsh-sx-head input::-webkit-search-cancel-button { display: none; }
.tsh-sx-head .tsh-kbd { margin-left: 0; }
.tsh-sx-res { overflow: auto; padding: 6px; }
.tsh-sx-g { padding: 8px 10px 4px; font-size: 11.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-4); }
.tsh-sx-i { display: flex; gap: 12px; padding: 10px; border-radius: 9px; align-items: flex-start; color: var(--ink); }
.tsh-sx-i:hover, .tsh-sx-i.is-act { background: #eef5ff; }
.tsh-sx-i .tsh-ico { color: var(--ink-4); margin-top: 2px; }
.tsh-sx-i b { display: block; font-size: 14px; font-weight: 600; }
.tsh-sx-i small { display: block; color: var(--ink-3); font-size: 12.5px; line-height: 1.4; }
.tsh-sx-i mark { background: #fff1b8; color: inherit; border-radius: 3px; padding: 0 1px; }
.tsh-sx-sub { color: var(--brand-3); font-weight: 600; }
.tsh-sx-empty { padding: 22px; text-align: center; color: var(--ink-3); font-size: 14px; }
.tsh-sx-empty b { color: var(--ink); }
.tsh-sx-foot { display: flex; gap: 16px; padding: 9px 10px 5px; border-top: 1px solid var(--line); margin-top: 4px; font-size: 12px; color: var(--ink-3); }
.tsh-sx-foot .tsh-kbd { margin-left: 0; }
@media (max-width: 640px) { .tsh-sx-foot { display: none; } .tsh-sx-ov { padding-top: calc(12px + env(safe-area-inset-top, 0px)); } }
