/* Client portal — same token system as the site.
   NO CLIENT-NAME PLACEHOLDER IN THIS FILE, deliberately. It is on the drift
   checker's shared list, which requires it to be byte-identical in every
   client. Seeding replaces the token in all text files, so a token here made
   a correctly-seeded client report as drifted forever — and 'fixing' that by
   copying from the Template put the raw placeholder back into three live
   client portals. A shared file cannot also be a personalised one.

   The tokens below are the same ones the site uses, so the portal quietly
   previews the design language the client will get. */

:root {
  --color-primary: #1a2a4a;
  --color-accent: #c9a227;
  --color-accent-text: #8c6b15; /* 4.97:1 on white — brand gold fails AA as text */
  --color-done: #1f7a3d;
  /* AG-141. The timeline pill needs a DARKER green than everything else that
     shows "done", because it is the only place the colour is covered by the 3D
     sheen -- and a 28% white top stop lifts #1f7a3d to rgb(94,159,115), where
     white text measures 3.14:1 against a 4.5 requirement.
     THE TOKEN PASSED AND THE PIXELS DID NOT: #1f7a3d is 5.37:1 flat, which is
     what anyone checking the token would have seen. The failure lived in the
     compositing, in neither colour.
     Only .tl-done a uses this. --color-done keeps #1f7a3d for card borders,
     side tabs and badges, which carry no sheen and were never in trouble;
     darkening that token would have repainted things that are already correct.
     Measured at the very TOP of the gradient rather than where the text
     happens to sit today, because a title that wraps puts its first line
     higher: 5.14:1 at the top stop, 13.08:1 at the bottom. */
  --color-done-pill: #0e4020;
  --color-n900: #17181c;
  --color-n700: #3d4048;
  --color-n400: #9aa0ab;
  --color-n100: #eef0f3;
  --color-n000: #ffffff;
  --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --sp-1: 0.5rem; --sp-2: 1rem; --sp-3: 1.5rem; --sp-4: 2.5rem;
}

*, *::before, *::after { box-sizing: border-box; margin: 0; }
body { font-family: var(--font-body); line-height: 1.6; color: var(--color-n900); background: var(--color-n000); }
img { max-width: 100%; height: auto; }
:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 2px; }

.portal-header { background: var(--color-primary); color: var(--color-n000); padding: var(--sp-2) var(--sp-3); }

/* When a demonstration notice is present the bar becomes two columns: the
   project title on the left, the notice along the right-hand edge. `order`
   does the placing, NOT the markup — the notice is first in the DOM so a
   screen reader meets the caveat before the project name, which is the part
   of the old full-width red band that was actually worth keeping.
   Only applied when the script adds .has-notice, so a real client's portal
   header is untouched. */
.portal-header.has-notice {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--sp-2);
}
.portal-header.has-notice .ph-text { order: 1; flex: 1 1 20rem; min-width: 0; }
.portal-header.has-notice .engagement-notice { order: 2; flex: 0 1 24rem; }
.portal-header h1 { font-size: 1.25rem; }
.portal-header p { color: var(--color-n100); font-size: 0.85rem; }

/* P3MAI brand lockup: icon-only pyramid SVG + two-tone HTML wordmark
   (P3M in white on the navy header, AI always gold — never flat) */
.brand-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
/* width+height attrs on the img plus an explicit ratio here reserve the logo's
   box before the SVG loads. Without it the header had zero height until load,
   then grew and pushed the whole page down - the single largest layout shift
   on the portal (CLS 0.922, and .shell reported as the shifting element). */
.brand-icon { width: 1.7rem; height: auto; aspect-ratio: 1 / 1; flex: none; }
.brand-word { font-weight: 800; letter-spacing: 0.02em; font-size: 1.1rem; color: var(--color-n000); }
/* The gold half of the wordmark. Named for what it colours: this portal
   reads "WebSandpit", so the class is .brand-sandpit and matches the
   WebSandpit site. WCAG 2.2 SC 1.4.3 excepts text that is part of a brand
   name from the contrast minimum -- a cited exemption, not a defect. */
.brand-sandpit { color: var(--color-accent); }

/* Two-column shell: book-tab column on the left, page content right */
.shell { display: flex; align-items: flex-start; max-width: 68rem; margin: 0 auto; padding: var(--sp-3); }
.side-col { flex: 0 0 12.5rem; position: sticky; top: 1rem; }
main { flex: 1; min-width: 0; padding-left: var(--sp-3); border-left: 2px solid var(--color-n100); }
h2 { color: var(--color-primary); margin: var(--sp-3) 0 var(--sp-1); }
.lead { color: var(--color-n700); max-width: 44rem; }

/* Book/spreadsheet-style tabs: rounded outer edge, colour-coded spine on the
   content side; the current page's tab lifts forward over the divider */
.side-tabs { display: flex; flex-direction: column; gap: 4px; }
.side-tab {
  display: block; text-decoration: none;
  background: var(--color-n100); color: var(--color-n700);
  /* 0.8rem vertical, not 0.6rem: at 0.6 the tabs read as cramped against the
     stage names they carry. Raising it changes the column height, so the
     :empty CLS reservation further down MUST be kept in step — it is derived
     from (tab height x 7) + (gap x 6), not guessed. */
  padding: 0.8rem 0.8rem; font-size: 0.85rem; font-weight: 600;
  border-radius: 8px 0 0 8px; border-right: 4px solid var(--color-n400);
  box-shadow: inset -3px 0 5px rgba(0, 0, 0, 0.05);
}
.side-tab:hover { background: var(--color-n000); }
.side-tab.st-done { border-right-color: var(--color-done); }
.side-tab.st-active-stage { border-right-color: var(--color-accent); }
/* The open tab stands out by lifting forward only — it keeps its own status
   spine colour, so gold stays reserved for the stage actually in progress */
.side-tab.is-current {
  background: var(--color-n000); color: var(--color-primary); font-weight: 800;
  box-shadow: 3px 4px 9px rgba(0, 0, 0, 0.18);
  position: relative; z-index: 1; margin-right: -2px; /* lifts over the divider */
}
.side-actions { display: flex; flex-direction: column; gap: var(--sp-1); margin-top: var(--sp-2); padding-right: 0.8rem; }
/* Sits under the download button and explains when to reach for it. Small,
   but not so faint it fails AA — this is the instruction a client needs at
   exactly the moment the email route has let them down. */
.side-note { font-size: 0.78rem; color: var(--color-n700); margin: 0; line-height: 1.4; }
.side-actions .button { text-align: center; font-size: 0.85rem; }

/* Under-timebar text: centred to sit in line with the timeline above it */
/* The iteration remark: ONE tinted block holding both sentences.
   Previously the tint sat on the charging sentence alone, inside an otherwise
   plain paragraph, so a single remark was rendered as two things and the tint
   attached itself to the money. The block is the frame around how a stage
   works; the sentences are its two halves. Left-aligned, because centred
   italics over two sentences is a paragraph pretending to be a caption. */
.timeline-note {
  font-size: 0.85rem;
  color: var(--color-n700);
  margin: 0.75rem auto 0;
  max-width: 46rem;
  background: #fdf4e3;
  border-left: 3px solid var(--color-accent);
  border-radius: 0 var(--radius, 6px) var(--radius, 6px) 0;
  padding: 0.7rem 0.9rem;
}
.timeline-note .tn-line { display: block; }
/* Space between the two sentences without a blank paragraph between them. */
.timeline-note .tn-line + .tn-line { margin-top: 0.4rem; }
/* The charging terms keep a little more weight than the sentence above: same
   block, same colour, but it is the half a client must not skim. */
.timeline-note .tn-line + .tn-line { font-weight: 600; color: var(--color-n900); }

/* Demonstration banner — shown on every page while this is not a real
   engagement, directly beneath the header and above the stage timeline.

   Deliberately NOT styled as a warning. Red and amber read as "something has
   gone wrong", and nothing has: it is a statement of what the reader is
   looking at. Brand gold on a pale ground carries the emphasis instead, and
   the left rule gives it weight without shouting.

   The gold left border is decoration, not the message — the tag word and the
   heading carry that in text, so the banner survives greyscale printing and
   any colour-vision difference. */
.engagement-notice {
  border-left: 4px solid var(--color-accent);
  background: #fbf7e9;                        /* a tint of the brand gold */
  padding: var(--sp-2) var(--sp-3);
  margin: 0;
}
/* Uppercase label, sized down but kept above 0.75rem so it stays legible */
.engagement-notice .en-tag {
  font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em;
  text-transform: uppercase;
  /* A shade darker than --color-accent-text. That token is tuned for white,
     where it gives 4.97:1; on this gold-tinted panel the same colour drops to
     4.63:1 — still AA, but with almost no margin on text this small. */
  color: #7a5d12;
  margin-bottom: 0.15rem;
}
.engagement-notice .en-head { font-weight: 700; color: var(--color-n900); }
.engagement-notice .en-body {
  font-size: 0.9rem; color: var(--color-n700); max-width: 60rem; margin-top: 0.2rem;
}

/* The LOUD variant, for a portal published publicly as a demonstration.
   A stranger can arrive here cold from a link, and the failure it prevents is
   someone believing they are looking at a real client's live project — so this
   one shouts where the standing notice above murmurs. It is also placed first
   in the document by the script, which means a screen reader meets it before
   the page heading rather than after the navigation.

   Dark ground with white text rather than the gold tint: at full width and
   centred it reads as a statement about the whole page, not a note attached to
   one section. Contrast is 9.6:1, so it survives greyscale and low vision. */
/* Inside the navy title bar, right-aligned. The dark red band this replaces
   ran the full width of the page above everything else: unmissable, and the
   first thing a visitor saw of a portal being shown off as a piece of work.
   Fair warning should not be the loudest object on the page. Here it is still
   on every page, still ahead of the heading for a screen reader, and no longer
   competing with the project it describes.
   No panel colour of its own — a second background inside the bar would just
   be a smaller band. It reads as part of the bar, set off by the gold rule. */
.engagement-notice.en-prominent {
  border-left: none;
  border-right: 3px solid var(--color-accent);
  background: transparent;
  color: #ffffff;
  text-align: right;
  padding: 0.1rem 0.75rem 0.1rem 0;
  margin: 0;
}
/* The tag line would otherwise keep its dark gold, which is unreadable on this
   ground. White at reduced opacity keeps the hierarchy without a second hue. */
.engagement-notice.en-prominent .en-tag { color: rgba(255,255,255,0.85); }
.engagement-notice.en-prominent .en-head,
.engagement-notice.en-prominent .en-body { color: #ffffff; }
.engagement-notice.en-prominent .en-body { max-width: none; font-weight: 400; }
/* Sized down to sit as a caption beside the title rather than as a headline
   above it. Kept above 0.75rem so it stays legible, and white on the navy bar
   is 12.6:1 — this is a caveat that must not be hard to read. */
.engagement-notice.en-prominent .en-head { font-size: 0.85rem; font-weight: 700; }
.engagement-notice.en-prominent .en-body { font-size: 0.78rem; color: var(--color-n100); }
/* On a phone the bar stacks, and a right-aligned caption under a left-aligned
   title looks like a mistake rather than a decision. */
@media (max-width: 40rem) {
  .portal-header.has-notice .engagement-notice {
    order: 3; flex: 1 1 100%;
    text-align: left;
    border-right: none;
    border-left: 3px solid var(--color-accent);
    padding: 0.1rem 0 0.1rem 0.75rem;
  }
}

/* The same point, restated inside any form carrying an approval control.
   Muted rather than repeating the banner's emphasis: by this scroll depth the
   reader has already met it once, and a second shout would read as nagging. */
.demo-form-note {
  border-left: 3px solid var(--color-n400);
  background: var(--color-n100);
  padding: var(--sp-1) var(--sp-2);
  margin-bottom: var(--sp-2);
  font-size: 0.85rem; color: var(--color-n700);
}

/* Desktop: each side control sits above its own caption, as a column. The
   wrapper exists for the phone layout below, and is inert here. */
.side-action { display: flex; flex-direction: column; }

/* ---- Small screens: the side column becomes ONE sliding bar ---------------
   It used to WRAP — seven tabs onto three or four rows, with the action
   buttons wrapping again beneath them. That is what read as cramped: a block
   of stacked pills where the eye expects a bar.

   Now it behaves exactly like .timeline directly above it: a single row that
   does not wrap and scrolls sideways. Consistency is the point — two strips
   one under the other that behave differently is worse than either choice on
   its own, because the reader learns the first one's rules and the second one
   breaks them.

   Tabs AND buttons share the one strip, so there is a single thing to slide
   rather than a slider followed by a wrapped block. */
@media (max-width: 820px) {
  /* stretch, not flex-start: in column direction flex-start sizes children
     to their content width, which lets the timeline force page h-scroll */
  .shell { flex-direction: column; align-items: stretch; }

  .side-col {
    position: static; flex: none; width: 100%;
    display: flex; flex-direction: row; flex-wrap: nowrap;
    align-items: flex-start; gap: 4px;
    overflow-x: auto;
    /* Room for the tab shadows and focus rings at both ends, the same reason
       .timeline carries side padding — without it the first and last item are
       clipped by this container's own overflow. */
    padding: 2px 6px 10px;
    /* Momentum scrolling on iOS. Unprefixed does not exist for this. */
    -webkit-overflow-scrolling: touch;
    /* NO scroll-snap here, deliberately. It was tried and removed: with
       `scroll-snap-type: x proximity` the strip re-snapped to a different item
       whenever the PAGE scrolled vertically, so the bar could quietly rest
       showing the buttons instead of the tabs -- caught because a capture came
       back with the tabs scrolled out of sight. The timeline this is meant to
       match has no snapping either, and matching it is the whole point. */
  }
  /* flex: none on both, or the browser shrinks them to fit and the strip
     stops scrolling — which silently restores the cramped layout. */
  .side-tabs, .side-actions {
    flex: none; flex-direction: row; flex-wrap: nowrap;
    align-items: flex-start; gap: 4px;
    margin-top: 0; padding-right: 0;
  }
  .side-tab {
    flex: none; white-space: nowrap;
    border-radius: 8px 8px 0 0; border-right: 0;
    border-bottom: 4px solid var(--color-n400); box-shadow: none;
  }
  .side-tab.st-done { border-bottom-color: var(--color-done); }
  .side-tab.st-active-stage { border-bottom-color: var(--color-accent); }
  .side-tab.is-current { margin-right: 0; }
  /* Each control keeps its caption directly beneath it as the strip slides.
     A width is needed because a caption is prose and would otherwise stretch
     the strip to the length of the sentence. */
  .side-action { flex: none; width: 11rem; }
  .side-action .button, .side-action .btn-clear { width: 100%; }
  .side-action .send-hint, .side-action .side-note { text-align: left; }

  main { padding-left: 0; border-left: 0; margin-top: var(--sp-2); }
}

/* Timeline — a scaled linear bar, present on every page. Segment width is
   proportional to the stage's suggested SLA days (flex-grow set inline). */
/* Side/bottom padding stops the rounded caps, shadows and the is-here
   outline being clipped by this container's own overflow at the ends. */
.timeline { list-style: none; padding: var(--sp-2) 14px 14px 6px; display: flex; gap: 3px; overflow-x: auto; }
/* The longest UNBREAKABLE word in any stage title has to fit: a single word
   cannot wrap, so if it exceeds the box it spills over the padding and reads
   as squashed. "Requirements" measured 82px in 80px of space — a 2px miss.
   min-width stays at 6.2rem deliberately: raising it to make room ate the
   free space that flex-grow distributes, and flattened the 2-day segments
   from ~2x the width of a 1-day segment to 1.25x, which would have destroyed
   the SLA scaling this timeline exists to show. The 2px is taken from
   padding and font-size below instead, where nothing else depends on it. */
.tl-step { flex-basis: 0; min-width: 6.2rem; display: flex; }
.tl-step a {
  display: flex; flex-direction: column; width: 100%; text-decoration: none;
  background: var(--color-n100); color: var(--color-n700);
  /* Horizontal padding is 0.25rem, not 0.5rem, and that is the whole fix for
     the "Requirements looks squashed" report. Measured: a 1-day segment is
     99px, and "Requirements" renders at 78px — so at 0.5rem padding the text
     had 83px to sit in and cleared each edge by 2.5px, which reads as
     squashed even though nothing wraps or clips. At 0.25rem it gets ~91px and
     ~6.5px each side. Vertical padding is unchanged.
     Widening the segment instead is NOT available: min-width is not the
     binding constraint (natural flex-grow gives 97-99px anyway), so the only
     way to make 1-day segments wider is to change the grow ratios — which are
     the SLA proportionality this timeline exists to show. */
  padding: 0.45rem 0.25rem; border-radius: 3px;
}
.tl-step:first-child a { border-radius: 999px 3px 3px 999px; }
.tl-step:last-child a { border-radius: 3px 999px 999px 3px; }
/* Safety net for stage titles a client renames: hyphenate rather than let a
   long word overflow, and reserve two lines so a title that wraps ("Launch &
   Handover") and one that doesn't ("Build") present the same block height —
   that is what keeps the six segments looking like one consistent set. */
.tl-title {
  display: block; font-size: 0.75rem; font-weight: 700; line-height: 1.25;
  text-align: center;
  /* -webkit- prefix is not optional: Safari has never shipped unprefixed
     `hyphens`, so without it the stage titles stop hyphenating on every Apple
     device and the segments go back to looking cramped — silently, and only
     for the users we cannot test on here. */
  overflow-wrap: break-word; -webkit-hyphens: auto; hyphens: auto;
}
/* min-height: 2.5em was REMOVED here on 2026-08-09. It reserved two lines for
   every title so that a wrapping stage and a non-wrapping one kept the same
   block height — but that reservation was redundant and was the main reason
   the bar looked poor: five of six titles are one line, so five segments
   carried a blank line between the title and the day count.
   Measured with it removed: all six .tl-step boxes stay 65px and every day
   label stays on one row, because .tl-step is a flex item (default stretch)
   and .tl-days already has margin-top:auto. Flexbox was doing the job the
   reservation was added for. */
/* margin-top:auto pins every day label to the segment's bottom edge, so the
   durations sit on one shared row even when a long title wraps above them */
/* NO OPACITY HERE -- it used to carry `opacity: 0.85`, and that was the second
   half of AG-141. Fading white text to 85% over the sheened pill costs about
   0.9 of a contrast point and dropped the day label to 4.23:1 even after the
   pill was darkened, so the colour fix alone would not have been enough.
   The opacity was redundant anyway: this label is already distinguished from
   the title by being 0.72rem, and a second signal that costs accessibility to
   repeat something the size already says is not worth having. Same reasoning as
   the no-redundant-underline rule two lines below. */
.tl-days { display: block; font-size: 0.72rem; margin-top: auto; padding-top: 0.15rem; text-align: center; }
.tl-done a { background: var(--color-done-pill); color: var(--color-n000); }
.tl-active a { background: var(--color-accent); color: var(--color-n900); }
.is-here a { outline: 2px solid var(--color-primary); outline-offset: 1px; }
/* No underline on the current step. The segment already takes a 2px outline
   above, and an outline is a SHAPE change rather than a colour one -- so
   WCAG 1.4.1 is met by that alone and the underline was a second non-colour
   signal stacked on a first. Three treatments for one state reads as noise
   and makes the timeline look busier than it is.
   NOTE this does NOT extend to the nav links on the client sites, where the
   underline is doing the 1.4.1 work on its own, nor to ordinary links. */
/* 3D treatment: top-light/bottom-dark gradient over each segment's status
   colour + a soft drop shadow falling bottom-right. Declared after the
   status rules so the gradient survives their background shorthand. */
.timeline a {
  background-image: linear-gradient(180deg,
    rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.06) 40%,
    rgba(0, 0, 0, 0.12));
  box-shadow: 3px 4px 9px rgba(0, 0, 0, 0.28),
    inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.timeline a:hover { box-shadow: 4px 6px 12px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.25); }
.timeline-meta { font-size: 0.8rem; color: var(--color-n700); margin-top: var(--sp-1); text-align: center; }

/* Status cards (overview board + testing list) */
.board { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: var(--sp-2); margin-top: var(--sp-2); }
.card { display: block; border: 1px solid var(--color-n100); border-left: 5px solid var(--color-n400); border-radius: 6px; padding: var(--sp-2); text-decoration: none; color: inherit; background: var(--color-n000); }
.card h3 { color: var(--color-primary); font-size: 1rem; margin: 0.3rem 0; }
.card p { font-size: 0.85rem; color: var(--color-n700); }
.st-done { border-left-color: var(--color-done); }
.st-active { border-left-color: var(--color-accent); }
.st-badge { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-n700); }
.st-done .st-badge { color: var(--color-done); }
.st-active .st-badge { color: var(--color-accent-text); }

/* Question card header: house-style line icon in a gold-tinted tile,
   plus a gold "N of 14" chip so each card reads as a landmark, not a row */
.q-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.6rem; }
.q-icon {
  width: 2.3rem; height: 2.3rem; border-radius: 8px; flex: none;
  background: rgba(201, 162, 39, 0.16); color: var(--color-primary);
  display: flex; align-items: center; justify-content: center;
}
.q-icon svg { width: 1.35rem; height: 1.35rem; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.q-chip { background: var(--color-accent); color: var(--color-n900); font-size: 0.7rem; font-weight: 700; padding: 0.2rem 0.65rem; border-radius: 999px; letter-spacing: 0.03em; }

/* Three-step flow. The themes bar is the page's one persistent element:
   it sticks to the top while scrolling, carrying the per-step counts that
   the old standalone progress pill used to show. */
/* The steps bar is the page's one persistent orientation device, so it is
   deliberately heavy: a banded container, taller segments, and a clear
   current/complete treatment that reads at a glance while scrolling. */
.steps-nav {
  display: flex; gap: 8px; flex-wrap: wrap;
  position: sticky; top: 0; z-index: 5;
  margin-top: var(--sp-2); padding: 0.7rem;
  background: var(--color-n100);
  border: 1px solid #d8dbe2;
  border-radius: 8px;
  box-shadow: 0 6px 14px -6px rgba(0, 0, 0, 0.5);
}
.step-seg {
  flex: 1 1 11rem; text-align: left; font: inherit; cursor: pointer;
  background: var(--color-n000); color: var(--color-n700);
  border: 1px solid #dfe2e8;
  border-left: 6px solid var(--color-n400); border-radius: 5px;
  padding: 0.7rem 0.85rem;
}
.step-seg:hover { border-left-color: var(--color-primary); }
.step-seg .seg-title { display: block; font-weight: 700; font-size: 0.92rem; color: var(--color-primary); }
.step-seg .seg-count { font-size: 0.78rem; }
.step-seg.is-current {
  border-left-color: var(--color-accent);
  background: #fffdf5;
  box-shadow: 0 0 0 2px var(--color-accent), 2px 4px 10px rgba(0, 0, 0, 0.2);
}
.step-seg.is-complete { border-left-color: var(--color-done); }
.step-seg.is-complete .seg-count { color: var(--color-done); font-weight: 700; }

/* Charge condition — stated on every page, never buried */
.terms-flag {
  display: block; margin-top: 0.4rem; font-style: normal;
  color: #7a4a12; background: #fdf4e3;
  border-left: 4px solid var(--color-accent);
  padding: 0.5rem 0.7rem; border-radius: 4px;
  font-size: 0.85rem; text-align: left;
}

/* Clear all — destructive, so visually quiet next to the primary action */
.btn-clear {
  background: transparent; border: 1px solid var(--color-n400);
  color: var(--color-n700); font: inherit; font-size: 0.8rem;
  padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer;
}
.btn-clear:hover { border-color: #8a2d14; color: #8a2d14; }

/* Attachment size guidance */
.file-limit { font-size: 0.78rem; color: var(--color-n700); margin-top: 0.4rem; }
.file-limit.is-over { color: #8a2d14; font-weight: 700; }

/* Sent states on the submit button */
#send-btn.is-sent {
  background: var(--color-done); color: #fff; border-color: var(--color-done);
  box-shadow: 0 0 0 3px rgba(31, 122, 61, 0.3);
}
#send-btn.is-sent:disabled { background: var(--color-done); color: #fff; cursor: default; }
#send-btn.is-handoff { background: var(--color-n000); color: var(--color-primary); border: 2px solid var(--color-done); }

/* Itemised cost breakdown (launch page) */
.cost-table { width: 100%; border-collapse: collapse; margin-top: var(--sp-2); font-size: 0.9rem; }
.cost-table th, .cost-table td { text-align: left; padding: 0.6rem 0.5rem; border-bottom: 1px solid var(--color-n100); vertical-align: top; }
.cost-table th { background: var(--color-primary); color: #fff; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
/* Every money column (AED, USD, GBP) right-aligns so figures line up on the
   decimal; nth-child(n+2) covers them all without needing a rule per currency. */
.cost-table td:nth-child(n+2),
.cost-table th:nth-child(n+2) { text-align: right; white-space: nowrap; }
.cost-table tfoot td { border-top: 2px solid var(--color-primary); border-bottom: 0; font-size: 1rem; }
.cost-table .is-variable td { background: #fdf4e3; }
.cost-detail { display: block; font-size: 0.8rem; color: var(--color-n700); font-weight: 400; }
/* ---- Design mock-ups (design page) --------------------------------------
   Two captures of the same page at different widths. Their natural shapes are
   wildly different — a full-page desktop render is wide and short, a phone one
   is narrow and very tall — so showing both at natural height would leave the
   phone column several times longer than the desktop one. Cropping each to a
   common height with object-position:top shows the part that carries the
   design decisions (masthead, hero, first cards) and keeps the pair level;
   the caption and hint say plainly that this is a crop, and either opens
   full size. */
.mock-set { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: flex-start; margin-top: var(--sp-2); }
.mock { margin: 0; }
.mock-desktop { flex: 1 1 22rem; min-width: 0; }
.mock-phone { flex: 0 0 11rem; }
.mock a { display: block; }
.mock img {
  display: block; width: 100%; height: 26rem;
  object-fit: cover; object-position: top;
  border: 1px solid var(--color-n100); border-radius: 4px;
}
.mock figcaption { font-size: 0.8rem; color: var(--color-n700); margin-top: 0.35rem; text-align: center; }
.mock-hint { font-size: 0.8rem; color: var(--color-n700); margin-top: var(--sp-1); }
@media (max-width: 560px) {
  /* Side by side would leave the phone crop about 5rem wide — too small to
     judge a design by. Stack instead and shorten both. */
  .mock-phone { flex: 1 1 100%; }
  .mock img { height: 18rem; }
}

.cost-note { font-size: 0.8rem; color: var(--color-n700); margin-top: var(--sp-1); }

/* ---- Layout-shift reservations ------------------------------------------
   The timeline, tab strip and status board are all written by portal.js from
   portal-data.js after the first paint, so the page grows underneath the
   reader as each one lands. Measured at Lighthouse's 412px mobile viewport,
   .shell grew 826px on load — a cumulative layout shift of 0.922, which alone
   dropped Performance from ~97 to the low 60s.

   The reservation is scoped with :empty so it applies ONLY before the region
   is filled. Once portal.js writes into it the selector stops matching, so an
   over-estimate never leaves a visible gap in the finished page.

   ⚠️ BUT AN OVER-ESTIMATE IS NOT FREE, and this comment used to say it was.
   Reserving 96px for a region that fills to 80px means the region SHRINKS by
   16px the moment it is filled, and everything below it jumps upward — which
   is a layout shift of exactly the kind these reservations exist to prevent.
   Too much and too little are both wrong; only close is right. Corrected
   2026-08-12 after measuring each region rather than trusting the reservation.

   HOW TO RE-MEASURE, because these numbers go stale whenever the portal's
   content changes: load a page, and for each region compare
   getBoundingClientRect().height against the value below at BOTH 412px (the
   width Lighthouse uses) and a desktop width. Do not measure inside an iframe
   — `frame-ancestors 'none'` in _headers blocks it, and the readings come back
   empty rather than wrong, which is easy to misread as "nothing shifted". */
/* 80px, not 95. The 95 was measured when the timeline was taller; the bar has
   since got shorter and the reservation was never re-measured, so on EVERY
   desktop width the region collapsed by 15px the moment the script filled it
   and pulled the page up under the reader. Re-measured 2026-08-14 at 824, 1000,
   1280 and 1350 — 80px at all four, which is why the mobile override below is
   now the same number rather than a different one. */
.timeline:empty { min-height: 80px; }
/* The line of text under the timeline. It was the last script-filled region
   with NO reservation at all, so it grew from zero to 41px on every page and
   pushed the entire status board down with it. Missed originally because it is
   a single <p> and looks too small to matter — but it sits near the top, so
   almost the whole viewport moves when it lands, and the size of a shift is
   how much of the screen moved, not how big the element is.
   MEASURED at 41px at both 412px and 824px — the text wraps to two lines at
   both, so one value covers the range. */
.timeline-meta:empty { min-height: 41px; }

/* The last two script-filled regions, and the reason they needed a different
   treatment from everything above (AG-131).
   The regions above are empty elements sitting in the page waiting to be
   filled, so `:empty` reserves their height from the first paint. These two
   used to be CREATED by the script from nothing -- so there was no element to
   reserve anything for, the browser drew the page without them, and 308px of
   content then appeared and shoved everything down. Measured CLS 0.33 against
   a 0.1 threshold.
   They are now placeholders in the HTML like the rest, and these two rules do
   the reserving. Measured at 1280px; the phone values are in the media query. */

/* THE BANNER IS CONDITIONAL, AND THAT IS WHY THIS SELECTOR IS AWKWARD.
   It appears on every portal EXCEPT a live client's -- and CSS has to decide
   before any script runs, so it reads the mode off the <html> element rather
   than from portal-data.js. Reserve it unconditionally and the first real
   client's portal carries a 220px blank space that never fills. */
/* COLLAPSE THE EMPTY BOX FIRST. `.engagement-notice` carries its own padding,
   so an empty one is still 32px tall -- which on a live portal, where it never
   fills, is 32px of blank space under the header forever. Zeroing the padding
   also makes the reservation below exact rather than reservation-plus-padding,
   which would over-reserve and shift the page UP when it filled. */
#engagement-notice:empty { padding: 0; border-left-width: 0; }
/* 82px, re-measured 2026-08-14 at 412, 830 and 1280px -- the same at all three,
   which is why the phone override that used to sit below has gone. It was 151
   here and 220 there, both measured when the banner text was longer, so the
   region collapsed by up to 138px the moment the script filled it and pulled
   the whole page up under the reader. That single mismatch was the portal's
   entire CLS.

   THIS NUMBER IS CONTENT-DEPENDENT. The banner text comes from portal-data.js,
   so a client whose engagement note runs to another line needs it re-measured.
   Nothing enforces that, which is why it is written here rather than assumed:
   measure the filled height, do not estimate it, and do not carry a figure over
   from another client.

   The live case is untouched and needs no reservation -- the rule is scoped to
   NOT-live, and the :empty rule above collapses the padding to zero, so a real
   client's portal has no gap rather than a smaller one. */
html:not([data-portal-mode="live"]) #engagement-notice:empty { min-height: 82px; }

/* The note appears on EVERY portal, but its HEIGHT depends on the mode after
   all: a real engagement adds the charging sentence, which on a phone takes it
   from 88px to 203px. One value cannot serve both, and guessing the middle
   would shift every portal in one direction or the other -- so it is keyed on
   the same signal as the banner. All four values MEASURED, not estimated. */
.timeline-note:empty { min-height: 66px; }
html[data-portal-mode="live"] .timeline-note:empty { min-height: 116px; }
.side-tabs:empty { min-height: 355px; }        /* 7 tabs x 47.3px + 6 gaps x 4px — MEASURED, not estimated */
.board:empty { min-height: 250px; }            /* multi-column at this width */
/* The step bar on the requirements page. It was the one script-filled region
   with no reservation, and it is the only one that appears on that page --
   which is why the requirements page shifted more than any other. Three
   segments; MEASURED at 1280px, not estimated. */
.steps-nav:empty { min-height: 98px; }
/* Between the 820px breakpoint and about 960px the three step segments wrap:
   the titles and the "9 of 9 answered" counts no longer fit on one row. The
   desktop reservation of 98px was 81px short across that whole band, which is
   the largest single shift on this page. Two steps because the wrap happens in
   two stages — three rows, then two. MEASURED at 830, 880, 920 and 960px. */
@media (min-width: 821px) and (max-width: 879px) {
  .steps-nav:empty { min-height: 179px; }
}
@media (min-width: 880px) and (max-width: 959px) {
  .steps-nav:empty { min-height: 121px; }
}

@media (max-width: 820px) {
  /* Tabs become a horizontal strip and the board collapses toward one column,
     so both are shorter/taller respectively than the desktop case. */
  /* 51px, not 162px. The old figure was measured when the tabs WRAPPED onto
     three or four rows; they are now a single sliding row, so the reservation
     had to be re-measured rather than adjusted by eye. Over-reserving is not
     harmless here -- the region would collapse by 111px the moment portal.js
     fills it, pulling the page up under the reader, which is the very shift
     these reservations exist to prevent. MEASURED at both 390px and 412px,
     the width Lighthouse reports CLS at, and identical at each. */
  .side-tabs:empty { min-height: 51px; }
  .board:empty { min-height: 780px; }
  /* The timeline is SHORTER on a phone, not taller: its segments sit closer
     together at this width. The desktop 95px was being applied here too, so
     the region shrank by 15px when filled and pulled the page up under the
     reader. MEASURED at 412px — the width Lighthouse reports CLS at. */
  .timeline:empty { min-height: 80px; }
  .timeline-note:empty { min-height: 88px; }
  html[data-portal-mode="live"] .timeline-note:empty { min-height: 203px; }
  /* The segments stack, so the bar is far taller on a phone. MEASURED at 375px. */
  .steps-nav:empty { min-height: 261px; }
}
/* Below ~560px three columns can't fit: "to be confirmed" is nowrap, so the
   table forces a minimum width wider than a 360px phone and the whole page
   scrolls sideways. Stack each row into a labelled block instead — the cost
   sheet is the one artifact the client must read before approving spend, so
   it has to be legible on a phone rather than merely reachable by scrolling. */
@media (max-width: 560px) {
  .cost-table, .cost-table tbody, .cost-table tfoot,
  .cost-table tr, .cost-table td { display: block; width: 100%; }
  .cost-table thead { display: none; }
  .cost-table tr { border-bottom: 1px solid var(--color-n100); padding: 0.5rem 0; }
  .cost-table td { border-bottom: 0; padding: 0.15rem 0.5rem; }
  .cost-table td:nth-child(n+2) {
    text-align: left; white-space: normal;
  }
  /* Column headers are hidden, so each figure carries its own currency label. */
  .cost-table td[data-label]::before {
    content: attr(data-label) " ";
    font-weight: 600; color: var(--color-n700);
  }
  .cost-table tfoot tr { border-top: 2px solid var(--color-primary); border-bottom: 0; }
  .cost-table tfoot td { border-top: 0; }
}
.step { display: none; }
.step.is-visible { display: grid; gap: var(--sp-2); }
/* Before the script runs, show the FIRST step. Every step was hidden until JS
   arrived and revealed one, so the form painted at nothing and then grew by a
   full step's height — the largest layout shift on the page, and the kind that
   moves whatever the reader had already started looking at.
   The script adds .js-ready as it takes over, after which it alone decides
   which step is visible. */
.portal-form:not(.js-ready) .step:first-of-type { display: grid; gap: var(--sp-2); }
.step-controls { display: flex; gap: var(--sp-2); align-items: center; justify-content: center; }
.button-ghost { background: transparent; border: 2px solid var(--color-primary); color: var(--color-primary); }

/* Send stays visible but inert until every question is answered, so the
   client can see the finish line without being able to send a part-answer */
#send-btn:disabled {
  background: var(--color-n100); color: var(--color-n400);
  box-shadow: none; cursor: not-allowed;
}
#send-btn.is-ready { box-shadow: 0 0 0 3px rgba(201, 162, 39, 0.4), 0 2px 6px rgba(0, 0, 0, 0.2); }
.send-hint { font-size: 0.75rem; color: var(--color-n700); text-align: center; margin-top: 0.1rem; }
.send-hint.is-ready { color: var(--color-done); font-weight: 700; }

/* Forms */
.portal-form { margin-top: var(--sp-2); display: grid; gap: var(--sp-2); max-width: 44rem; }
.q { border: 1px solid var(--color-n100); border-radius: 6px; padding: var(--sp-2); }
.q label, .q legend { font-weight: 700; color: var(--color-primary); display: block; }
/* Indented and rule-marked so the QUESTION is what the eye lands on. Without
   this the explanation has the same left edge as the question it explains, and
   at only a small size/colour difference the two read as one block of text —
   clients reported the question not standing out. */
.q .why {
  font-size: 0.85rem; color: var(--color-n700); margin: 0.3rem 0 var(--sp-1);
  padding-left: 0.7rem; border-left: 2px solid var(--color-n400);
}
/* Only the phrase is emphasised, not the explanation. Bold-italicising the
   whole paragraph made it heavier than the question it sits under, which is
   the opposite of the hierarchy this block exists to create. */
.q .why-label { font-weight: 700; font-style: italic; }
.q input[type="text"], .q input[type="email"], .q textarea, .q select {
  width: 100%; padding: 0.6rem; border: 1px solid var(--color-n400); border-radius: 4px; font: inherit;
}
.q textarea { min-height: 5.5rem; }
/* Example answers: italic light grey, gone the moment the client types */
.q textarea::placeholder, .q input::placeholder { color: var(--color-n400); font-style: italic; opacity: 1; }

/* File attachment area within a question */
.file-drop { margin-top: var(--sp-1); padding: var(--sp-1); border: 2px dashed var(--color-n400); border-radius: 6px; }
.file-drop label { font-weight: 400; font-size: 0.85rem; color: var(--color-n700); display: block; margin-bottom: 0.3rem; }
.file-list { list-style: none; padding: 0; margin-top: 0.4rem; font-size: 0.85rem; }
.file-list li {
  padding: 0.15rem 0; color: var(--color-primary);
  display: flex; align-items: center; gap: 0.5rem;
}
.file-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
/* A real button, not a styled span: it must be reachable by keyboard and
   announced as a control. 44px is not required for an inline text control at
   AA, but the touch target is kept generous because this is the one action a
   client takes when they have already made a mistake. */
.file-remove {
  flex: none; font: inherit; font-size: 0.78rem; font-weight: 700;
  color: #8a2d14; background: none; border: 1px solid currentColor;
  border-radius: 4px; padding: 0.2rem 0.55rem; cursor: pointer; min-height: 1.9rem;
}
.file-remove:hover { background: #8a2d14; color: var(--color-n000); }
.file-remove:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 2px; }
fieldset.q { border: 1px solid var(--color-n100); }
.choice { display: flex; gap: 0.5rem; align-items: flex-start; margin: 0.4rem 0; font-weight: 400; }
.button { display: inline-block; background: var(--color-accent); color: var(--color-n900); border: 0; padding: 0.7rem var(--sp-3); border-radius: 4px; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; }
.button:hover { filter: brightness(1.08); }
/* A button that LEAVES for another site. Outlined rather than filled, because
   three gold buttons in a column all shout equally and a way out is not the
   thing we are asking anyone to do — it just has to be findable. Navy on white
   is 13.4:1, so quiet here means visually recessive, never hard to read. */
.button-quiet {
  background: transparent;
  color: var(--color-primary);
  border: 1px solid var(--color-n400);
  font-weight: 600;
}
.button-quiet:hover { filter: none; background: var(--color-n100); border-color: var(--color-primary); }
#submit-note, .placeholder-note { background: var(--color-n100); border-radius: 6px; padding: var(--sp-2); margin-top: var(--sp-2); font-size: 0.9rem; }

/* Build checklist */
.build-list { list-style: none; padding: 0; margin-top: var(--sp-2); }
.build-list li { padding: 0.5rem 0; border-bottom: 1px solid var(--color-n100); }
.bi-done { color: var(--color-done); }

.portal-footer { text-align: center; font-size: 0.8rem; color: var(--color-n700); padding: var(--sp-4) var(--sp-2); }
.portal-footer .button { margin-top: var(--sp-1); font-size: 0.9rem; }

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

/* --- Design stage: revision history + in-place requirements editing ------ */
/* Past rounds are kept but visually recessive: the client should land on the
   latest design, with earlier ones available for comparison rather than
   competing for attention. */
.rev { margin-top: var(--sp-2); }
.rev-past { opacity: 0.72; }
.rev-past:hover, .rev-past:focus-within { opacity: 1; }
.rev-head {
  font-size: 0.95rem; margin: 0 0 0.2rem; color: var(--color-primary);
  display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.rev-tag {
  font-size: 0.7rem; font-weight: 700; letter-spacing: 0.03em;
  background: var(--color-accent); color: var(--color-n900);
  padding: 0.1rem 0.5rem; border-radius: 999px;
}
.rev-note { font-size: 0.85rem; color: var(--color-n700); margin: 0 0 0.4rem; }

/* Each requirement is a normal .q, so it inherits the label and textarea
   styling the client already met at stage 1 — the same question should not
   look like a different kind of thing on a different page. */
.req-item { margin-top: var(--sp-1); }
.req-item textarea { min-height: 4.5rem; }
.req-empty { font-size: 0.9rem; color: var(--color-n700); }

/* The read-only record of captured answers, shown above the form on the
   requirements page. Set apart as a panel so it reads as "what is on file"
   rather than as more questions to answer. */
.req-record { background: var(--color-n100); border: 1px solid var(--color-n400);
  /* 6px, not var(--radius). That token is not defined anywhere in this
     stylesheet, so the declaration was invalid and silently dropped -- CSS
     does not error on an undefined custom property, it just discards the
     line. The panel has been rendering with square corners against a design
     of rounded ones, and nothing said so. Found by checking every var()
     against every definition, which is now worth doing after any CSS edit. */
  border-radius: 6px; padding: var(--sp-2) var(--sp-3);
  /* margin-TOP matters as much as bottom. This panel is injected by the script
     directly beneath the Back / Next step buttons, and it had only a bottom
     margin -- so the captured answers sat hard against the controls, reading
     as part of the form rather than as a record of what is on file. The gap is
     what separates "things you press" from "things you have already said".
     Spotted by Douglas on the Riverbend Joinery demo, 2026-08-13; fixed here
     in the Template because all five client portals had it too. */
  margin-top: var(--sp-4);
  margin-bottom: var(--sp-3); }
.req-record h3 { margin: 0 0 var(--sp-1); }
.req-record-note { font-size: 0.85rem; color: var(--color-n700); margin: 0 0 var(--sp-2); }
.req-record dl { margin: 0; }
/* The question quiet, the answer loud: the client is scanning for what THEY
   said, not re-reading what we asked. */
.req-record dt { font-size: 0.8rem; color: var(--color-n700); margin-top: var(--sp-2); }
.req-record dd { margin: 0.15rem 0 0; font-weight: 500; }

/* The example-estimate caption on a demonstration's cost table. Tinted so it
   cannot be skimmed past on the way to the figures underneath it. */
.cost-example { background: var(--color-n100); border-left: 3px solid var(--color-accent);
  padding: var(--sp-1) var(--sp-2); margin-bottom: var(--sp-2); }

/* ---- Choice cards on the requirements form -------------------------------
   Two groups of six and one of five: what the top of the page DOES, how it
   LOOKS, and what sort of logo. They are the only questions whose answer
   changes the SHAPE of the page rather than its palette or its words.

   Radios dressed as cards, not clickable divs: one choice, arrow-key navigable
   within the group, announced as a set, and no JavaScript involved.

   THE RADIO STAYS VISIBLE. Hiding it and faking the tick is the usual trick and
   the usual bug -- the selected state then rests on colour alone, which WCAG
   2.2 does not accept, and the form becomes unreadable printed in greyscale.
   Here the selection is carried three ways: the dot, a heavier border, and a
   tinted ground.

   `label.choice`, not `.choice`. These cards are labels inside a question, and
   a rule like `.q label` -- one class plus an element -- outranks a bare class.
   That exact collision flattened the same cards on another form in this estate;
   the tell was that grid-template-columns applied while display did not. */
.choice-set { border: 0; padding: 0; margin: 0; }
.choice-set legend { font-weight: 700; padding: 0; color: var(--color-n900); }
label.choice {
  display: grid; grid-template-columns: auto 1fr; gap: 2px 12px;
  align-items: start; padding: 0.7rem 0.8rem; margin-bottom: 0.4rem;
  background: var(--color-n000); border: 1px solid var(--color-n400);
  border-radius: 6px; cursor: pointer;
}
label.choice:hover { border-color: var(--color-primary); }
label.choice input {
  grid-row: 1 / span 2; margin: 3px 0 0; width: 18px; height: 18px;
  accent-color: var(--color-primary);
}
.choice-label { font-weight: 700; }
.choice-hint { grid-column: 2; color: var(--color-n700); font-size: 0.85rem; }
label.choice:has(input:checked) {
  border-color: var(--color-primary); border-width: 2px;
  background: var(--color-n100); padding: calc(0.7rem - 1px) calc(0.8rem - 1px);
}
label.choice:has(input:focus-visible) { outline: 3px solid var(--color-primary); outline-offset: 2px; }

/* The questions we cannot build without. Marked in WORDS rather than with an
   asterisk: an asterisk is a convention a reader has to already know, and
   colour alone is not a signal WCAG 2.2 accepts. */
.req {
  font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.06em; color: var(--color-accent-text); margin-left: 0.4rem;
}

@media (min-width: 46rem) {
  /* Two columns once there is room. Six stacked cards is a long scroll for a
     question meant to be answered at a glance. */
  .choice-set { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-1); }
  .choice-set legend, .choice-set .why { grid-column: 1 / -1; }
}
