/* Portwest Assistant — application stylesheet.
   The visual authority for this file is DESIGN.md at the project root. Values
   marked (est.) there were read off screenshots of the in-build website; if the
   digital team ever ships real tokens, they win.

   Nothing outside the token block defines a colour, a duration or a radius. */

:root {
  /* Brand — authoritative, from the March 2022 brand guide. */
  --port-blue: #003e52;
  --alert-orange: #ff6800;
  --charcoal: #222223;

  /* Category palette, brand guide. Each is tied to a product category there,
     which is why a category accent has an authored source, not a chosen one. */
  --cat-air: #00a9e0;
  --cat-earth: #efdf00;
  --cat-fire: #c8102e;
  --cat-water: #47d7ac;

  /* Neutrals — sampled from the site screenshots, then corrected for contrast.
     Measured: the original --ink-faint #8fa0b3 was 2.68:1 on white and failed
     AA on nine separate surfaces. These values are the shallowest step that
     clears 4.5:1 while keeping soft/faint distinguishable from each other. */
  --white: #ffffff;
  --ink: #1c2734;        /* 13.2:1 on white */
  --ink-soft: #47566b;   /* 7.47:1 on white, 6.28:1 on canvas */
  /* Christian's reference name for this one: Slate. It is the "Portwest
     Assistant" label colour and the Verified sources heading colour - now also
     the suggested-question colour, so all three read as one family. */
  --ink-faint: #5f7285;  /* 4.96:1 on white, 4.66:1 on wash */
  --rule: #e3e8ee;
  --rule-soft: #eff2f6;
  --wash: #f6f8fa;
  --blue-tint: #cfdde3;
  --blue-line: #1a5063;
  --blue-hover: #0a536b;

  /* The ground the panels float on. Tinted from Port Blue rather than a neutral
     grey, so the whole shell reads as one brand surface. */
  --canvas: #e7ecf0;

  /* Status. Used only for data-source honesty and errors. */
  --warn-bg: #fff7d6;
  --warn-line: #eadb9a;
  --warn-ink: #6b5b0e;
  --err: #b91c1c;
  --err-bg: #fdeaea;
  --err-line: #f3c4c4;
  /* Component tints, promoted out of the rules that used to hard-code them.
     app.css claimed nothing outside this block defined a colour; it did, in
     eight places. Every ratio below was measured against its own tint. */
  /* "Verbatim from the record". Darkened from #67a06b (WP4): 3.08:1 on white
     was under the 4.5:1 AA floor at 10.5px bold, which does not qualify for
     the large-text exemption. #3f7a44 clears it at 5.15:1 while staying the
     same hue - still reads as its own green, just no longer the exception
     the old comment carved out for it. --ok and .pill.verified, the
     AA-passing green this was once scoped against instead of just fixing,
     were never emitted by app.js and are deleted (WP4) too - this is that
     colour's only real consumer. */
  --verbatim: #3f7a44;
  /* Each source tag now wears the identity of the system it names.
     Media Hub is Portwest's brand name for Bynder, and #16a0db is its own
     brand blue - taken from the supplied logo, not sampled by eye. */
  --mediahub: #16a0db;   --mediahub-tint: #e6f5fc;
  /* The product record takes the purple the asset library used to wear. */
  --record: #86198f;     --record-tint: #f4e9f7; /* 7.00:1 */
  /* Guidance - SOPs and brand documents. Alert Orange, per Christian. The solid
     DRAFT pill is the only other orange badge and it is filled, not tinted, so
     the two do not read as the same thing. */
  --guidance: #b34900;   --guidance-tint: #fff1e6;
  /* Generated content. #d2e04e is a light chartreuse - too light to carry text -
     so the tint is the pale version and the ink is a darkened sibling, the same
     text-on-faded-self shape every other tag uses. */
  --generated: #55611a;  --generated-tint: #f2f7d5;
  --warn-code: #f3ead0;
  --preview-ink: #ffd9b8;                        /* 8.76:1 on Port Blue */

  /* Type. Two faces; the kit publishes din-2014 in 400 and 700 ONLY. Display
     type is Geom Graphic REGULAR (400) in all caps — headings and CTAs both. */
  --f-display: "geom-graphic", "din-2014", -apple-system, sans-serif;
  --f-body: "din-2014", -apple-system, "Segoe UI", Roboto, sans-serif;

  /* Type scale. These were declared in DESIGN.md and absent here, so
     var(--f-display) was hand-repeated 21 times with its own size and tracking
     each time - which is how four off-scale sizes got in. Roles, not values. */
  --t-h1:    24px;  --t-h1-ls:    .06em;
  --t-h2:    13px;  --t-h2-ls:    .12em;
  --t-h3:    15px;  --t-h3-ls:    .06em;
  --t-label: 11px;  --t-label-ls: .12em;
  --t-body:  15px;
  --t-data:  13px;
  --t-meta:  11.5px;
  /* The real, load-bearing "micro-label" size - pills, small-CTA text, trace
     tags, provenance lines - was hand-typed 18 times with no name of its own
     (WP4). */
  --t-micro: 10.5px;

  /* Space — 4-based. Do not invent intermediate values. */
  --s1: 4px;  --s2: 8px;  --s3: 12px; --s4: 16px;
  --s5: 24px; --s6: 32px;

  /* Radius, stepping down with nesting depth so an inner corner never looks
     tighter than the corner it sits inside. Concentric elements would take the
     strict inner = outer - inset rule; these are contained rather than
     concentric (a card sits in 24px of panel padding, which would take the
     inner radius to zero), so the scale is proportional instead: each level is
     two thirds of the one outside it. */
  --r: 9px;      /* outermost: the floating panels */
  --r-in: 6px;   /* nested inside a panel: cards, buttons, fields, cert block */
  --r-sm: 4px;   /* nested inside those: chips, tags, pills, thumbnails */

  /* Buttons — three sizes, not the seven the app had drifted to. Primary is
     Send and every sign-in button; small is every secondary CTA that sits
     inside a card (Open document, Build document, Save, Export...); chip is
     the tier bar and the suggested-question row. One letter-spacing value
     for every one of them, so "how CTA text sits" stops being a per-button
     decision. */
  --btn-h: 44px;
  --btn-h-sm: 30px;
  --btn-h-chip: 38px;
  --btn-ls: .1em;

  /* Panels float on the canvas, so they carry a real shadow — offset and soft
     blur, tinted from Port Blue rather than black. */
  --lift: 0 1px 2px rgba(0, 62, 82, .04), 0 6px 20px rgba(0, 62, 82, .07);
  --lift-high: 0 12px 40px rgba(0, 62, 82, .12);

  --dur-fast: 120ms; --dur-mid: 180ms; --dur-slow: 240ms;
  --ease-out: cubic-bezier(.2, .8, .25, 1);
  --ease-in: cubic-bezier(.4, 0, 1, 1);
}

* { box-sizing: border-box; margin: 0; }

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

body {
  font-family: var(--f-body);
  font-size: var(--t-body);
  line-height: 1.6;
  color: var(--ink);
  background: var(--canvas);
  height: 100vh;
  display: flex;
  flex-direction: column;
  padding: var(--s3);
  gap: var(--s3);
}

/* Browser surfaces the design still owns — craft-floor.md is right that these
   ship with defaults belonging to no design system. */
::selection { background: var(--port-blue); color: var(--white); }
/* Port Blue, not orange: measured, orange rings were 2.44-2.90:1 on this app's
   three light grounds, under the 3:1 non-text floor. Port Blue is 9.75-11.6:1.
   On Port Blue surfaces the ring inverts to orange (3.99:1) - one exception,
   declared, rather than one colour that fails half the app. */
:focus-visible { outline: 2px solid var(--port-blue); outline-offset: 2px; }
.utility :focus-visible, .certblock :focus-visible,
.sitefoot :focus-visible { outline-color: var(--alert-orange); }
button, input, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }

/* Scrollbars, themed from the palette rather than left to the platform. */
* { scrollbar-width: thin; scrollbar-color: var(--rule) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--rule); border-radius: var(--r);
                            border: 3px solid var(--white); }
::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }

/* Visually hidden, still announced. Used for the assistant view's h1, the
   composer label, the trace glyphs' text alternative and the status region. */
.sr {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
  white-space: nowrap; border: 0;
}
/* Visible only on focus - the standard skip-link pattern. */
.skip {
  position: absolute; left: var(--s3); top: -100px; z-index: 10;
  background: var(--port-blue); color: var(--white);
  padding: var(--s3) var(--s4); border-radius: var(--r-in);
  font-family: var(--f-display); font-weight: 400; font-size: var(--t-label);
  letter-spacing: var(--t-label-ls); text-transform: uppercase;
  text-decoration: none;
}
.skip:focus { top: var(--s3); }

/* The transcript is focusable (see index.html on why) so it needs a ring, but
   it is a scroll container rather than a control - a subtler one. */
#chat:focus-visible { outline: 2px solid var(--port-blue); outline-offset: -2px; }

.u-label {
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-label); letter-spacing: var(--t-label-ls);
  text-transform: uppercase; line-height: 1.4;
}

/* A panel: white, floating on the canvas, one radius. Every major region of the
   interface is one of these, which is what makes the shell read as nested
   surfaces rather than a page divided by lines. */
.panel {
  background: var(--white); border-radius: var(--r); box-shadow: var(--lift);
  overflow: hidden; min-height: 0;
}

/* ============================================================ chrome
   The site's two top bands - Port Blue utility row, white masthead - live in
   ONE floating panel here. On portwest.com they run edge to edge because the
   page scrolls under them; in an app shell that never scrolls, a single nested
   panel is the honest translation of the same structure. */

.topbar { flex-shrink: 0; padding: var(--s3); }

.utility {
  background: var(--port-blue); color: var(--blue-tint);
  display: flex; align-items: stretch; flex-wrap: wrap;
  gap: var(--s4); padding: 0 var(--s4); min-height: var(--btn-h-chip);
  /* Inset from the panel by the same gap that separates the panels themselves,
     so it reads as a bar nested inside the chrome rather than a band welded to
     its edge. Nothing touches an edge, so it rounds on all four corners. */
  border-radius: var(--r-in);
}
/* Every label here is two or three words of letterspaced caps; none is long
   enough that breaking inside it ever helps. The container wraps between
   items instead. */
.utility > *, .utility button { white-space: nowrap; }
.utility .u-label { display: flex; align-items: center; }
.utility .spacer { flex: 1; }

.tiers { display: flex; align-items: stretch; }
/* The tier is a server-side fact; this control only asks the server to change
   it, and an internal preview is audit-logged. */
.tiers button, .utility form button, .utility .navbtn {
  background: none; border: none; color: var(--blue-tint);
  padding: 0 var(--s3);
  font-family: var(--f-display); font-weight: 400;
  /* --t-meta, not --t-label (WP4): the label scale is one more distinct
     button font-size than the app needs, and this bar's caps read the same
     at half a point smaller - folding it into the size chips and the flag
     link already use keeps the whole button system at three sizes. */
  font-size: var(--t-meta); letter-spacing: var(--t-label-ls); text-transform: uppercase;
  transition: color var(--dur-fast) var(--ease-out);
}
.tiers button:hover, .utility form button:hover, .utility .navbtn:hover {
  color: var(--white);
}
.tiers button.active, .utility .navbtn.active { color: var(--white); }
/* Under the words, not over them. Rounded on the top corners and square where
   it meets the bar's own bottom edge - a radius against a container edge reads
   as a mistake rather than a shape. Drawn as a pseudo-element because a border
   cannot carry a radius of its own. */
.tiers button, .utility .navbtn { position: relative; }
.tiers button.active::after, .utility .navbtn.active::after {
  content: ""; position: absolute; left: var(--s3); right: var(--s3); bottom: 0;
  height: 3px; background: var(--alert-orange); border-radius: 3px 3px 0 0;
}

/* Shown ONLY when the data is not live. A badge confirming everything is fine
   is noise; a badge saying the certifications on screen are fictional is the
   most important thing on the page. See renderSourceTag in app.js. */
/* Scoped to .utility so it out-specifies `.utility .u-label { display: flex }`
   above - which it did not, and the empty badge showed as a cream box.
   :empty is the belt to that braces: no text, no badge, whatever the cascade
   does. */
.utility .sourcetag {
  display: none; align-items: center; padding: var(--s1) var(--s3);
  margin: var(--s1) 0; border-radius: var(--r-sm);
  background: var(--warn-bg); color: var(--warn-ink);
}
.utility .sourcetag:empty { display: none; }
.utility .sourcetag.is-fallback { background: var(--err); color: var(--white); }

.masthead {
  position: relative;
  background: var(--white);
  display: flex; align-items: center; gap: var(--s5);
  padding: var(--s2) var(--s3) var(--s4);
}
.brand { display: block; line-height: 0; }
/* 60% of the 36px the Portwest-only mark carried (Christian, 27 Aug). The new
   lockup is a much wider landscape wordmark, so at the old height it ran a
   third of the way across the masthead. */
/* 918.71 x 57.04 (16.1:1), the new lockup (10 Sep) - about 1% narrower than
   the previous 923.99 x 57.04. The height is unchanged: both artboards are
   57.04 tall and set at the same type size, so 21.6px is still the same cap
   height, giving a measure of about 348px. */
.brand img { height: 21.6px; display: block; }
/* .masthead nav / nav span deleted (WP4): index.html's masthead only ever
   held .brand and .fill - the real Assistant/Publisher/Dashboard buttons live
   in .utility, not here. It now also holds .navicon (WP9): Report a problem,
   the one control that belongs on the brand's own line rather than among the
   session-state controls underneath it. */
.masthead .fill { flex: 1; }

/* 44px hit area (WCAG target size) holding a glyph shrunk to 90% of the WP9b
   size (Christian, 10 Sep: "shrink the icon to 90%, don't let it affect the
   height of the container/panel it sits in" - 34px * .9 = 30.6, kept
   fractional since SVG width/height take it exactly). The glyph is the only
   thing drawn - no border, background or box-shadow at rest or hover, at
   any size - so resizing it never grows a visible control around it.
   padding: 0 overrides the UA button default (1px 6px) - see WP9b.
   Taken out of flex flow (position: absolute) so the 44px box can never set
   the masthead's height: .brand img's 21.6px line does that alone, with the
   icon shown or hidden. right: 0 sits it against the padding edge, the
   containing block an absolutely positioned child gets - the same edge a
   flex item would sit against. top centres it on the *content* box rather
   than the padding box: the masthead's top and bottom padding differ (s2 vs
   s4), so a plain top: 50% lands 4px off the lockup's centre; the calc
   subtracts that asymmetry. Below 720px the padding is symmetric (s3 both
   sides) so top: 50% is exact there - reset in that media query. */
.masthead .navicon {
  position: absolute; right: 0;
  top: calc(var(--s2) + (100% - var(--s2) - var(--s4)) / 2);
  transform: translateY(-50%);
  display: flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; padding: 0;
  background: none; border: none; box-shadow: none;
  color: var(--ink-faint);
  transition: color var(--dur-fast) var(--ease-out);
}
.masthead .navicon:hover, .masthead .navicon:focus-visible {
  color: var(--alert-orange); background: none; border: none; box-shadow: none;
}
/* Overrides the markup's width/height="34" (WP9b) to the 90% Christian asked
   for on 10 Sep, in CSS rather than index.html - one file to touch, one
   place the size lives. */
.masthead .navicon svg { display: block; width: 30.6px; height: 30.6px; }

/* Footer: the logomark alone on the canvas, bottom left. Nothing to enclose, so
   no panel - the mark sits ON the ground the panels float above. */
/* Right-aligned as a group, so the logomark closes the line it signs off
   rather than sitting alone in the opposite corner (Christian, 27 Aug). The
   login and chooser pages carry the mark with no text beside it; flex-end
   puts it in the same corner there too, so the mark's home is the same on
   every page. */
.appfoot {
  flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end;
  gap: var(--s4); padding: 0 var(--s2) var(--s1);
}
.appfoot img { height: 14px; display: block; }
.appfoot p { font-size: var(--t-meta); color: var(--ink-soft);
             text-align: right; }

/* ============================================================ banners */

.notice {
  background: var(--warn-bg); border: 1px solid var(--warn-line);
  border-radius: var(--r); color: var(--warn-ink);
  padding: var(--s3) var(--s4); font-size: 13px; line-height: 1.5;
  flex-shrink: 0;
}
.notice code {
  background: var(--warn-code); padding: 1px 5px; border-radius: var(--r-sm);
  font-size: 12px;
}
.previewbar {
  background: var(--port-blue); color: var(--preview-ink); border-radius: var(--r);
  padding: var(--s2) var(--s4); font-size: 12px; flex-shrink: 0;
  box-shadow: var(--lift);
}

/* ============================================================ layout */

main { flex: 1; display: flex; gap: var(--s3); min-height: 0; }

.chatcol { flex: 1.45; display: flex; flex-direction: column; min-width: 0; }

/* Extra top padding, measured (27 Aug): .srchead's own align-items: baseline
   pushes "Verified sources" ~12.6px below its panel's padding edge, because
   the row baseline-aligns against the taller Clear button beside it. Rather
   than touch a baseline rule that is doing its job, "Portwest Assistant" gets
   the same push here so the two panels' headings land on one line. */
#chat {
  flex: 1; overflow-y: auto; padding: var(--s5);
  padding-top: calc(var(--s5) + var(--s3));
  display: flex; flex-direction: column; gap: var(--s5);
}

/* ============================================================ conversation */

/* 68ch was a reading measure borrowed from prose. This column is not an
   article: it carries certification data and product names that wrap badly
   when squeezed, and the panel sat half empty at desktop widths. */
.msg { max-width: 92ch; }

/* A message enters with a fade and a 4px rise. One authored moment, not an
   effect on every element. */
@keyframes enter { from { opacity: 0; transform: translateY(4px); } }
.msg, .trace, .errmsg, .certblock, .docresult, .turn, .fbform, .fbdone {
  animation: enter var(--dur-mid) var(--ease-out) both;
}

.msg.user {
  align-self: flex-end; background: var(--port-blue); color: var(--white);
  padding: var(--s3) var(--s4); border-radius: var(--r-in);
  font-size: var(--t-body); max-width: 92ch;
}

.msg.bot { align-self: flex-start; font-size: var(--t-body); }
.msg.bot > * + * { margin-top: var(--s3); }
.msg.bot b { font-weight: 700; color: var(--port-blue); }
.msg.bot .quote {
  display: block; position: relative;
  padding-left: var(--s3); color: var(--ink-soft);
}
.msg.bot .quote::before {
  content: ""; position: absolute; left: 0; top: 2px; bottom: 2px;
  width: 2px; border-radius: 2px; background: var(--alert-orange);
}
/* Who is speaking, in the smallest mark that does the job. No avatar. */
.msg.bot::before {
  content: "Portwest Assistant"; display: block;
  font-family: var(--f-display); font-weight: 400;
  font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
  color: var(--ink-faint); margin-bottom: var(--s2);
}

.trace { align-self: flex-start; display: flex; gap: var(--s1); flex-wrap: wrap;
         margin-top: calc(var(--s5) * -1 + var(--s2)); }
.trace span {
  font-size: var(--t-micro); color: var(--ink-soft);
  background: var(--wash); border: 1px solid var(--rule);
  border-radius: var(--r-sm); padding: 2px var(--s2);
}
.trace span.miss { background: var(--err-bg); border-color: var(--err-line);
                   color: var(--err); }

.thinking {
  align-self: flex-start; color: var(--ink-faint); font-size: 13px;
  display: flex; align-items: center; gap: var(--s2);
}
/* The Assistant's own mark breathing, rather than an anonymous dot - the one
   place in the app where a wait is long enough to be worth branding, and the
   speech bubble is the thing a reader associates with being answered. The
   mountain was the mark here until the Assistant had one of its own (WP7).
   Square, because assistant-mark.svg is. */
.thinking::before, .bpulse::before {
  content: ""; width: 16px; height: 16px; flex-shrink: 0;
  background: var(--alert-orange);
  -webkit-mask: url("/static/assistant-mark.svg") no-repeat center / contain;
  mask: url("/static/assistant-mark.svg") no-repeat center / contain;
  animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: .28; } }

.errmsg {
  align-self: flex-start; background: var(--err-bg);
  border: 1px solid var(--err-line); color: var(--err);
  padding: var(--s3) var(--s4); border-radius: var(--r-in);
  font-size: 13px; max-width: 92ch;
}

/* --- certification block.
   THE most important surface in the app. Rendered from the product record,
   never from model text, and it must read as DATA - a different material from
   the prose above it. A Port Blue panel does that in one move. */

/* Quieted (Christian, 26 Aug): this was a full Port Blue slab and it dominated
   the transcript. Now a light card - tinted ground, hairline rule, no shadow -
   which still reads as a different MATERIAL from the prose around it (the
   requirement in PRODUCT.md) without shouting over it. */
.certblock {
  /* The same top-lit gradient the generated-document card uses (Christian,
     1 Sep). Flat --wash read as a filled box; lifting the top edge to white
     makes the panel sit on the page rather than being stamped onto it, and it
     is now one material across every tile in the transcript. */
  background: linear-gradient(to top, var(--wash), var(--white));
  color: var(--ink);
  border: 1px solid var(--rule); border-radius: var(--r-in);
  padding: var(--s4) var(--s5) var(--s5);
  max-width: 92ch; align-self: flex-start;
}
.certblock .hd {
  display: flex; align-items: baseline; gap: var(--s3);
  padding-bottom: var(--s3); margin-bottom: var(--s2); position: relative;
}
.certblock .hd::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; border-radius: 1px; background: var(--rule);
}
.certblock .hd h3 {
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-h2); letter-spacing: var(--t-h2-ls); text-transform: uppercase;
  color: var(--alert-orange);
}
.certblock .hd .code {
  font-family: var(--f-display); font-weight: 400; text-transform: uppercase;
  font-size: var(--t-data); letter-spacing: .06em; color: var(--port-blue);
  margin-left: auto;
}
.certrow {
  position: relative;
  display: grid; grid-template-columns: 1fr auto;
  gap: 0 var(--s4); padding: var(--s3) 0;
  /* Rows stagger in as the block lands. --i is set per row in app.js. */
  animation: enter var(--dur-mid) var(--ease-out) both;
  animation-delay: calc(var(--i, 0) * 20ms);
}
.certrow::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; border-radius: 1px; background: var(--rule);
}
.certrow:last-child { padding-bottom: 0; }
.certrow:last-child::after { display: none; }
.certrow .std { font-weight: 700; font-size: var(--t-data); color: var(--port-blue); }
.certrow .rating {
  font-family: var(--f-display); font-weight: 700; text-transform: uppercase;
  font-size: var(--t-data); letter-spacing: .04em;
  /* Port Blue on the light card. The rating is the value a reader copies into
     an email, so it takes the 11.6:1 pairing rather than the 2.73:1 that orange
     would give on this ground. Orange stays on the block's own heading. */
  color: var(--port-blue); text-align: right; white-space: nowrap;
}
.certrow .note {
  grid-column: 1 / -1; font-size: var(--t-meta); color: var(--ink-soft);
  margin-top: 2px;
}

/* The size qualifier. A record whose certified class varies by size produces
   two rows, and without this they read as a contradiction rather than a size
   split. PRODUCT.md: making this subtle is a regression, however pretty. */
.certrow .scope {
  grid-column: 1 / -1; margin-top: var(--s1);
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-label); letter-spacing: .08em; text-transform: uppercase;
  color: var(--ink-faint);
}
.certrow .scope.cond {
  color: var(--white); background: var(--alert-orange);
  display: inline-block; justify-self: start;
  padding: 3px var(--s2); border-radius: var(--r-sm);
}
.certblock .condnote {
  position: relative;
  display: flex; gap: var(--s2); margin-top: var(--s4);
  padding-top: var(--s3);
  font-size: var(--t-meta); line-height: 1.5; color: var(--ink);
}
.certblock .condnote::before {
  content: ""; position: absolute; left: 0; right: 0; top: 0;
  height: 1px; border-radius: 1px; background: var(--rule);
}
/* flex-shrink:0 because this is a flex row: without it the label wraps to
   "Varies / by size." mid-phrase. */
/* Alert Orange, not the muted --guidance (Christian, 27 Aug). Measured 2.73:1
   on the wash - under AA - so this stays a LABEL only: the sentence beside it
   carries the actual instruction at --ink (13.2:1), and the size qualifier it
   refers to is rendered separately in the row above at full contrast. Nothing
   safety-critical is legible only in this colour. */
.certblock .condnote b { color: var(--alert-orange); flex-shrink: 0; }
.certblock .prov {
  margin-top: var(--s3); font-size: var(--t-micro); letter-spacing: .03em;
  color: var(--ink-soft);
}
.certblock .none { font-size: var(--t-data); color: var(--ink-soft); }

/* --- generated-document result card. The money shot: someone pastes nine
   style codes and this appears. */

.docresult {
  /* Same sides as .certblock, .compare and .builder - one material across
     every tile, and one measure their text lines up on. This was the odd one
     out at s3/s4 while the rest sit at s4/s5 (Christian, 4 Sep: "things
     aren't lining up"). */
  align-self: flex-start; max-width: 92ch; width: 100%;
  border: 1px solid var(--rule); border-radius: var(--r-in);
  overflow: hidden;
  background: linear-gradient(to top, var(--wash), var(--white));
}
.docresult .top {
  display: flex; align-items: center; gap: var(--s3); position: relative;
  padding: var(--s4) var(--s5);
}
.docresult .top::after {
  content: ""; position: absolute; left: var(--s5); right: var(--s5); bottom: 0;
  height: 1px; border-radius: 1px; background: var(--rule);
}
.docresult .top h3 {
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-h3); letter-spacing: var(--t-h3-ls); text-transform: uppercase;
  color: var(--port-blue);
}
.docresult .body { padding: var(--s4) var(--s5) var(--s5); }
.docresult .meta { font-size: 12px; color: var(--ink-soft); }
.docresult .excl {
  margin-top: var(--s3); padding: var(--s3);
  background: var(--err-bg); border-radius: var(--r-sm);
  font-size: 12px; color: var(--err);
}
.docresult .excl li { margin-left: var(--s4); }
/* No gradient of its own any more: the card carries it now, and a second copy
   here restarted the ramp partway down and printed a visible seam. */
.docresult .foot {
  display: flex; align-items: center; gap: var(--s3); position: relative;
  padding: var(--s4) var(--s5); flex-wrap: wrap;
}
/* Opening a draft is a secondary action; it should not carry the weight of Send,
   which is the control that starts work. Same face and caps, sized to the DRAFT
   tag beside it rather than to the composer button. */
/* One declaration for both Open buttons - the document card's in the chat and
   the source card's in the panel opposite (Christian, 27 Aug). They open the
   same artefact, so they were never meant to be two different sizes; .card
   .btn only adds the margin its position needs. */
.docresult .btn, .card .btn {
  min-height: var(--btn-h-sm); padding: 0 var(--s4);
  font-size: var(--t-micro); letter-spacing: var(--btn-ls); border-width: 1px;
}
.docresult .foot::before {
  content: ""; position: absolute; left: var(--s4); right: var(--s4); top: 0;
  height: 1px; border-radius: 1px; background: var(--rule);
}

.pill {
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-micro); letter-spacing: .1em; text-transform: uppercase;
  padding: 3px var(--s2); border-radius: var(--r-sm); white-space: nowrap;
}
.pill.draft { background: transparent; color: var(--alert-orange);
               box-shadow: inset 0 0 0 1px var(--alert-orange); }
/* .pill.verified (--ok / --ok-tint) deleted (WP4): app.js never emits a
   verified pill - grep confirms "verified" appears only in code comments -
   so this styled an element that does not exist. */

/* Every CTA in the interface: Geom Graphic Regular, all caps, WHITE on Alert
   Orange (Christian, 26 Aug). Noted for the record: that pairing measures
   2.90:1, under the 4.5:1 AA floor - a brand decision taken knowingly, and the
   2px border keeps the control's own edge legible against any ground.
   Hover inverts to an outline: no fill, orange border, orange text. */
.btn {
  display: inline-flex; align-items: center; gap: var(--s2);
  background: var(--alert-orange); color: var(--white); text-decoration: none;
  border: 2px solid var(--alert-orange); border-radius: var(--r-in);
  padding: var(--s3) var(--s5);
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-data); letter-spacing: var(--btn-ls); text-transform: uppercase;
  /* Without this a CTA inherits body's 1.6 line-height, so a one-word label
     pushes the box past --btn-h and every "44px" button rendered a
     different actual height depending on its own font-size (WP4). A button
     label is one short line; it does not need prose leading. */
  line-height: 1;
  white-space: nowrap; min-height: var(--btn-h);
  transition: background var(--dur-fast) var(--ease-out),
              color var(--dur-fast) var(--ease-out);
}
.btn:hover { background: transparent; color: var(--alert-orange); }
.btn:disabled { opacity: .4; cursor: default; }

/* ============================================================ composer */

.chips {
  display: flex; gap: var(--s2); flex-wrap: wrap;
  padding: 0 var(--s5) var(--s5);
}
.chips button {
  background: var(--white); border: 1px solid var(--rule); border-radius: var(--r-sm);
  padding: var(--s2) var(--s3); font-size: var(--t-meta); color: var(--ink-faint);
  text-align: left;
  transition: border-color var(--dur-fast) var(--ease-out),
              background var(--dur-fast) var(--ease-out);
}
.chips button:hover { border-color: var(--alert-orange);
                      color: var(--alert-orange); background: transparent; }
/* Matched to the Port Blue bar in the header (the chip size, --btn-h-chip),
   not the 44px touch-target floor: these already clear the 24px WCAG 2.5.8
   minimum, and the header bar is the nearer visual reference. */
.chips button { min-height: var(--btn-h-chip); }

.composer {
  position: relative;
  /* Every inset around the field is the same measure - above, below, both
     sides, and the gap through to the button - so the bar sits in an even
     margin rather than a slot. The grey fades up into the transcript instead
     of starting on a hard line.
     align-items: center, not the flex default of stretch (WP4): Send used to
     stretch to match the textarea's own height, so a multi-line paste made
     the button taller than every other primary CTA in the app - one more
     button height nobody had named. Centred, it stays var(--btn-h) always. */
  display: flex; align-items: center; gap: var(--s5); padding: var(--s5);
  background: linear-gradient(to top, var(--wash), var(--white));
}
/* Divides the suggested questions from the prompt bar - same drawn-line
   pattern as .srchead::after, inset from the panel edge rather than touching
   it. */
.composer::before {
  content: ""; position: absolute; left: var(--s5); right: var(--s5); top: 0;
  height: 1px; border-radius: 1px; background: var(--rule);
}
/* A textarea, not an input: the flyer entry route is a pasted list of style
   codes, and a single-line field makes that feel wrong. */
.composer textarea {
  flex: 1; border: 1px solid var(--rule); border-radius: var(--r-in);
  padding: 15px var(--s4); font-size: var(--t-body); line-height: 1.5;
  resize: none; min-height: 52px; max-height: 160px;
  background: var(--white);
  transition: border-color var(--dur-fast) var(--ease-out);
}
/* One line, not two (Christian, 26 Aug): the border itself becomes the focus
   indicator at 2px Port Blue (11.6:1 against white, well over the 3:1 floor for
   a non-text indicator), and the outline ring is suppressed so they don't
   double up. The indicator is still unmistakably present - it is just one
   stroke rather than a ring outside a border. */
.composer textarea:focus-visible {
  border-color: var(--port-blue); border-width: 2px; outline: none;
  padding: 14px calc(var(--s4) - 1px);
}
/* 15px top/bottom padding (above) centres one 21px line in the 52px resting
   field, rather than pinning it to the top. --ink-faint is the same tone as the
   PORTWEST HELPER label and the copy under Verified sources. */
.composer textarea::placeholder { color: var(--ink-faint); opacity: 1; }
/* Send is a plain .btn now (WP4) - it was a hand-rolled duplicate of the
   same recipe, and the two only ever drifted apart, never on purpose. */

/* ============================================================ source panel */

/* Same gradient as the composer. The mask that used to fade the bottom edge to
   transparent (26 Aug) also faded the panel's OWN edge - the rounded corner
   and shadow that say "this is a panel" smudged into the canvas behind it, so
   the column stopped reading as a separate surface from whatever sat below it.
   The left panel never masks its edges; this one shouldn't either. */
.sourcecol {
  flex: 1; overflow-y: auto; min-width: 0; padding: var(--s5);
  background: linear-gradient(to top, var(--wash), var(--white));
}
/* Every divider in the app is drawn, not bordered, so it can have rounded
   ends (Christian, 26 Aug). A border-bottom is always a square-ended box edge;
   a positioned pseudo-element with a border-radius is a line. */
.srchead {
  position: relative; display: flex; align-items: baseline; gap: var(--s3);
  margin-bottom: var(--s4); padding-bottom: var(--s3);
}
.srchead::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; border-radius: 1px; background: var(--rule);
}
/* Set to match the "PORTWEST ASSISTANT" label in the chat panel opposite: same
   face, size, tracking and colour, both one panel-inset from the top, so the
   two columns start on the same line. */
.srchead h2 {
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-label); letter-spacing: var(--t-label-ls);
  text-transform: uppercase; color: var(--ink-faint);
}
.srchead p { font-size: 11.5px; color: var(--ink-faint); margin-top: 3px; }
/* Outline and label the SAME colour (Christian, 3 Sep). Matched on the text's
   colour rather than the border's: --rule is a hairline grey and a label in it
   measures about 1.3:1, which is not a legibility judgement anyone would want
   made for them. */
/* Border and label both the grey of the VERIFIED SOURCES heading beside it
   (Christian, 3 Sep), so the control belongs to that line rather than
   competing with it. */
.srchead button {
  margin-left: auto; background: none; border: 1px solid var(--ink-faint);
  color: var(--ink-faint); border-radius: var(--r-sm);
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-micro); letter-spacing: .08em; text-transform: uppercase;
  padding: var(--s1) var(--s2);
  transition: border-color var(--dur-fast) var(--ease-out),
              color var(--dur-fast) var(--ease-out);
}
.srchead button:hover { border-color: var(--port-blue);
                        color: var(--port-blue); background: transparent; }
/* The visible chip stays small; --btn-h is the WCAG 2.5.8 touch-target floor,
   the same one every primary button in the app already meets. */
.srchead button { min-height: var(--btn-h); display: inline-flex; align-items: center; }

/* Each answer's sources stay on the page: a document link generated three
   questions ago must still be reachable. */
.turn {
  position: relative; padding-left: var(--s3); margin-bottom: var(--s5);
}
.turn::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0;
  width: 2px; border-radius: 2px; background: var(--rule);
}
.turn.latest::before { background: var(--alert-orange); }
.turnq { font-size: 11.5px; color: var(--ink-soft); margin-bottom: var(--s2);
         line-height: 1.45; }
.turnq b { color: var(--port-blue); font-weight: 700; }
.turnq .when { color: var(--ink-faint); }

.card {
  background: linear-gradient(to top, var(--wash), var(--white));
  border: 1px solid var(--rule); border-radius: var(--r-in);
  padding: var(--s4); margin-bottom: var(--s3); font-size: 13px;
}
/* Tags and pills sit in one flex row rather than as inline boxes, so they share
   a baseline instead of each resolving its own against the line box. */
.tagrow { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
/* The gap below belongs to the source card, where the row sits ABOVE the
   title. In .docresult .top the row sits BESIDE the title in a centred flex
   line, and a bottom margin there lifted the DRAFT pill off the heading's
   centre - the same "invisible margin, not a size mismatch" alignment bug as
   .card .sys. Scoped rather than reset in two places. */
.card .tagrow { margin-bottom: var(--s3); }
.card .sys, .tagrow .sys, .tagrow .pill {
  display: inline-flex; align-items: center; margin: 0;
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-micro); letter-spacing: .1em; text-transform: uppercase;
  padding: 0 var(--s3); border-radius: var(--r-sm); height: 24px;
}
/* Direct-child only: a sys tag standing alone above a card's content still
   needs space below it. Inside a .tagrow this same selector used to win the
   cascade over the row's own margin:0 reset - the "sys" tag then carried an
   8px bottom margin its .pill sibling did not, which is what actually threw
   Generated document/Draft out of vertical alignment despite equal heights. */
.card > .sys { margin-bottom: var(--s2); }
/* One rule for every source tag: the system's own colour as text on a faded
   version of itself, all at one height so a row of them lines up. */
.sys.record { background: var(--record-tint); color: var(--record); }
.sys.doc { background: var(--guidance-tint); color: var(--guidance); }
.sys.generated { background: var(--generated-tint); color: var(--generated); }
.sys.asset { background: var(--mediahub-tint); color: var(--mediahub); }
/* The Media Hub tag shows the system's own mark instead of a word - a reader
   recognises it faster than a label, and it is the only tag that can. */
.sys.asset img { height: 12px; display: block; }
/* The style code is the thing a reader is looking FOR in a stack of cards, so
   it carries the accent and the name follows it in Port Blue. */
.card h3 .code { color: var(--alert-orange); }
.card h3 {
  font-family: var(--f-display); font-weight: 400; text-transform: uppercase;
  font-size: var(--t-data); letter-spacing: .04em; color: var(--port-blue);
  line-height: 1.3;
}
.card .meta { color: var(--ink-soft); font-size: var(--t-meta); margin-top: var(--s1); }
/* The fabric composition line is one long run of prose otherwise - bolding
   each "Label:" (Insulatex™ Pro:, Body Lining:...) gives the eye a place to
   land per material without changing a character of the verbatim text. */
.card .meta b { color: var(--ink); font-weight: 700; }
/* The source card's "Open" link measured 24.6 x 14 - the smallest target in
   the app, and it opens a generated document. */
.card .meta a { display: inline-flex; align-items: center; min-height: 44px;
                color: var(--port-blue); font-weight: 700; }
.card ul { padding-left: var(--s4); margin-top: var(--s2); }
.card li { margin: var(--s1) 0; }
.card .verbatim { font-size: var(--t-micro); color: var(--verbatim); margin-top: var(--s2);
                  font-weight: 700; }
/* The generated-document card's Open button, sized against the header's Port
   Blue bar (38px) rather than inheriting the full 44px composer-Send CTA it
   has no business matching - it is a secondary link inside a small card, and
   the base .btn size dwarfed it. */
.card .btn { margin-top: var(--s3); }
.card .thumbs { display: flex; gap: var(--s1); flex-wrap: wrap;
                margin: var(--s2) 0; }
/* 87px, up 50% from 58 (Christian, 27 Aug). Bynder derivatives are square with
   ~46% white padding around the garment, so the product itself occupies barely
   half the box - at 58px a hi-vis vest on white was unreadable as a shape. */
.card .thumbs img {
  width: 87px; height: 87px; object-fit: contain; background: var(--white);
  border: 1px solid var(--rule); border-radius: var(--r-sm); display: block;
}
/* Each thumbnail opens its own Media Hub page, so it needs to look openable. */
.card .thumbs a { display: block; border-radius: var(--r-sm);
                  transition: border-color var(--dur-fast) var(--ease-out); }
.card .thumbs a:hover img { border-color: var(--mediahub); }
/* Colourway swatches. Portwest's own artwork from the PIM (1 Sep) - the
   outlined circle below used to be the whole story, on the belief that the PIM
   held colour NAMES and not values, so a filled swatch would have asserted a
   shade nobody supplied. It holds both, and the files render a two-tone
   colourway as the two-tone it is.

   The outline survives as the FALLBACK, keeping exactly the meaning it had:
   no value anybody signed off. An img that 404s takes .missing and lands back
   on it, so a colour with no artwork looks like what it is. */
.swatches { display: flex; gap: var(--s1); align-items: center;
            margin-top: var(--s2); }
/* No outline on a swatch that HAS artwork - the frame crops the file's own
   keyline and nothing is drawn on top of it (Christian, 1 Sep). The outline
   survives only where there is no artwork at all, which is the one case it
   still means something. */
.swatches span, .swatches .swimg.missing {
  width: 10px; height: 10px; border: 1.5px solid var(--ink-faint);
  border-radius: 50%; flex-shrink: 0; background: none;
}
.swatches .swimg { border: none; }
/* The frame crops the artwork's own dark keyline: the image is drawn at 110%
   and the frame hides the overhang, so what shows is the colour rather than a
   bordered picture of it. */
.swatches .swimg {
  width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0;
  overflow: hidden; display: block;
}
.swatches .swimg img {
  width: 116%; height: 116%; margin: -8%; object-fit: cover; display: block;
}
.swatches .swimg.missing img { display: none; }
.swatches .swlabel {
  border: none; width: auto; height: auto; margin-left: var(--s1);
  font-size: var(--t-meta); color: var(--ink-soft);
}
/* Shot type and its usage right are one item, not two lines - the flat list
   made it impossible to tell which right applied to which photograph. */
.assetlist { list-style: none; padding: 0; margin-top: var(--s3); }
.assetlist li {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--s4); padding: var(--s2) 0; margin: 0; position: relative;
}
.assetlist li:not(:last-child)::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; border-radius: 1px; background: var(--rule-soft);
}
.assetlist .shot { font-weight: 700; color: var(--ink); }
.assetlist .rights {
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-micro); letter-spacing: .08em; text-transform: uppercase;
  color: var(--mediahub); white-space: nowrap;
}

.empty {
  color: var(--ink-faint); font-size: 12.5px; line-height: 1.6;
  padding: var(--s4) 0; width: 33%; min-width: 22ch;
}

/* ========================================================== feedback
   The report form, opened either from the header icon (into .fbbar, which
   sits outside every view so it works on all three) or under one answer
   (into .fbslot). Same form, same two fields, same card recipe - a form
   that looks like a different application is a form people distrust.

   .fbbar is positioned (fixed, computed from the icon's own rect in app.js -
   the app shell never scrolls, so a fixed popover never drifts from its
   trigger) rather than a full-width bar the way it was before WP9: a report
   is a small, anchored thing, not a second header. .fbslot stays in the
   transcript's own flow - it already sits directly under "Flag this answer"
   without needing position of its own - but takes the same card recipe via
   .fbform/.fbdone below, so both entry points read as one popover. */

.fbbar {
  position: fixed; z-index: 30;
  width: 320px; max-width: calc(100vw - 2 * var(--s4));
}
.fbbar:empty { display: none; }
.fbslot { width: 320px; max-width: 100%; }
.fbform, .fbdone {
  background: var(--white); border: 1px solid var(--rule); border-radius: var(--r-in);
  padding: var(--s4); box-shadow: var(--lift);
}
.fbform { display: flex; flex-direction: column; gap: var(--s3); }
.fbrow { display: flex; align-items: center; gap: var(--s3); }
.fbform select, .fbform textarea {
  border: 1px solid var(--rule); border-radius: var(--r-in); background: var(--wash);
  padding: var(--s2) var(--s3); font-size: 13px; line-height: 1.5; color: var(--ink);
  transition: border-color var(--dur-fast) var(--ease-out);
}
.fbform textarea { resize: vertical; min-height: 64px; width: 100%; }
.fbform select:focus-visible, .fbform textarea:focus-visible {
  border-color: var(--port-blue); outline: none;
}
.fbfoot { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap; }
.fbfoot .btn { min-height: var(--btn-h-sm); padding: 0 var(--s4); font-size: var(--t-micro);
               letter-spacing: var(--btn-ls); border-width: 1px; }
.fbmsg { font-size: var(--t-meta); color: var(--ink-faint); }
/* Two sentences now (WP9), not a one-line confirmation, so body ink and
   normal weight - bold at this length reads as a heading, not a reply. */
.fbdone { font-size: 13px; line-height: 1.5; color: var(--ink); }

/* The success card holds, then leaves under its own steam rather than being
   yanked away: a short rise-and-fade, continuing the same upward direction
   it entered on (the shared "enter" keyframe above, which .fbform/.fbdone
   both use). --ease-in because this is the one exit in the app - arriving
   decelerates into place, leaving accelerates away, and nothing else here
   had a reason to use the token before. Escape/outside-click/Cancel close
   instantly, same as every other dismissal in the app; this animation is
   reserved for the timed auto-dismiss alone (app.js), where a message that
   is otherwise just gone reads as a glitch rather than as finished. */
@keyframes fbleave { to { opacity: 0; transform: translateY(-6px); } }
.fbdone.leaving { animation: fbleave var(--dur-mid) var(--ease-in) both; }

/* Quiet by design: it sits under every answer, and an answer is what the
   reader came for. Underlined on hover so it reads as the link it is. */
.msgfoot { margin-top: var(--s2); }
.flaglink {
  border: none; background: none; padding: 0; cursor: pointer;
  font-size: var(--t-meta); color: var(--ink-faint);
}
.flaglink:hover { color: var(--alert-orange); text-decoration: underline; }
.fbslot:not(:empty) { margin-top: var(--s3); }

/* ============================================================ audit */

/* Every rule below names BOTH views. They were once written
   `#auditview, #builderview .thing`, which is one selector for #auditview
   itself and one for #builderview's descendant - so the Dashboard inherited
   `max-width: 340px` from the answer column and rendered in a 340px strip
   beside an empty page. */
#auditview, #builderview { flex: 1; overflow-y: auto; padding: var(--s5); display: none;
             background: var(--white); }
#auditview .lead, #builderview .lead { margin-bottom: var(--s5); max-width: 78ch; }
#auditview .lead h1, #builderview .lead h1 {
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-h1); letter-spacing: var(--t-h1-ls); text-transform: uppercase;
  color: var(--port-blue);
}
#auditview .lead p, #builderview .lead p { font-size: 13px; color: var(--ink-soft);
                     margin-top: var(--s2); }
#auditview .subhead, #builderview .subhead {
  font-family: var(--f-display); font-weight: 400;
  font-size: var(--t-h2); letter-spacing: var(--t-h2-ls); text-transform: uppercase;
  color: var(--port-blue); margin-bottom: var(--s2);
}
#auditview .subnote, #builderview .subnote { font-size: 12.5px; color: var(--ink-soft); margin-bottom: var(--s4); }
#auditview .faqwrap, #builderview .faqwrap { margin-bottom: var(--s6); }
/* A question group and a feedback report are the same card. The box itself is
   .dcard's now; this only stacks the parts on one gap, so a report with no
   exchange attached sits the same as one that has. */
.faqcard { display: flex; flex-direction: column; gap: var(--s2); }
.faqhead { display: flex; align-items: baseline; gap: var(--s3); }
.faqcount {
  flex-shrink: 0; background: var(--port-blue); color: var(--white);
  font-family: var(--f-display); font-weight: 400; font-size: var(--t-micro);
  letter-spacing: .08em; text-transform: uppercase;
  /* Same pill shape as the outcome beside it, so a count and a state read as
     one family of marks rather than two. */
  padding: 2px var(--s2); border-radius: 999px;
}
.faqq { font-weight: 700; color: var(--ink); font-size: 13px; }
/* A drawn line, not a border-left: every divider in this app is a positioned
   pseudo-element so it can have rounded ends, and a 2px coloured box edge on
   a callout is exactly what craft-floor.md refuses. */
.faqans {
  position: relative; font-size: 12.5px; color: var(--ink-soft);
  line-height: 1.5; padding-left: var(--s3);
}
.faqans::before {
  content: ""; position: absolute; left: 0; top: 2px; bottom: 2px;
  width: 2px; border-radius: 2px; background: var(--rule);
}
.faqans b { color: var(--ink); }
/* The two gap lists: a question and how often it was asked, nothing else.
   They are a list of findings, so they are a list, not a table of two
   columns pretending the second one is data. */
.qgap { list-style: none; padding: 0; }
.qgap li { position: relative; padding: var(--s2) 0; font-size: var(--t-data); }
.qgap li:not(:last-child)::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; border-radius: 1px; background: var(--rule-soft);
}
.qgap b { color: var(--ink); font-weight: 700; display: block; }
.qgap span { font-size: var(--t-meta); color: var(--ink-faint); }
/* Everything a disclosure reveals sits one step below its own summary. */
.faqcard .fold > :not(summary) { margin-top: var(--s2); }
.faqcard textarea {
  width: 100%; border: 1px solid var(--rule); border-radius: var(--r-in);
  padding: var(--s3); font-size: 13px; line-height: 1.5; min-height: 64px;
  resize: vertical; background: var(--wash);
  transition: border-color var(--dur-fast) var(--ease-out);
}
.faqcard textarea:focus-visible { border-color: var(--port-blue); outline: none; }
.faqfoot { display: flex; align-items: center; justify-content: space-between;
           gap: var(--s3); flex-wrap: wrap; }
.faqmeta { font-size: var(--t-meta); color: var(--ink-faint); }
/* Same small-CTA recipe as .docresult/.card .btn and .builder .bfoot .btn -
   this one used to be 38px with nothing explaining why (Christian, WP4). */
.faqfoot .btn { min-height: var(--btn-h-sm); padding: 0 var(--s4); font-size: var(--t-micro);
                letter-spacing: var(--btn-ls); border-width: 1px; }
/* ------------------------------------------------------------- dashboard
   Five tabs on the same surfaces the rest of the app uses - the audit
   table, the FAQ card, the builder's own list row. No new visual language
   for the operator's view, because it is the same product. */

/* The preamble is trimmed a step against Publisher's (--s4, not --s5, on both
   the lead and this row): the Overview has to put four figures and a
   thirty-day chart above the fold of a 1280x800 laptop, and 32px of chrome
   above them was what pushed the chart off it. */
#auditview .lead { margin-bottom: var(--s4); }
.dashtabs { display: flex; gap: var(--s2); flex-wrap: wrap;
            border-bottom: 1px solid var(--rule); margin-bottom: var(--s4); }
.dashtab {
  border: none; background: none; cursor: pointer; position: relative;
  display: inline-flex; align-items: center; min-height: var(--btn-h-chip);
  padding: 0 var(--s4); color: var(--ink-soft);
  font-family: var(--f-display); font-weight: 400; font-size: var(--t-meta);
  letter-spacing: var(--btn-ls); text-transform: uppercase;
}
.dashtab:hover { color: var(--port-blue); }
.dashtab.active { color: var(--port-blue); }
.dashtab.active::after {
  content: ""; position: absolute; left: var(--s4); right: var(--s4); bottom: -1px;
  height: 2px; background: var(--alert-orange);
}
/* ONE FILTER ROW per list, above everything it scopes - never a filter per
   card (dataviz interaction.md). The native input and select recipe is the
   app's own; only the row is new. */
.dashbar { display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap;
           margin: var(--s4) 0; }
.dashbar input, .dashbar select {
  border: 1px solid var(--rule); border-radius: var(--r-in); background: var(--wash);
  padding: var(--s2) var(--s3); font-size: 13px; color: var(--ink); min-height: 30px;
}
.dashbar input[type="search"] { min-width: 26ch; }
.dashbar .btn { min-height: var(--btn-h-sm); padding: 0 var(--s4); font-size: var(--t-micro);
                letter-spacing: var(--btn-ls); border-width: 1px; }
/* "Hide asked once" and "Open only": a checkbox on the same line as the
   selects, sized to the small-button height so the row keeps one baseline. */
.dashbar .check {
  display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
  font-size: var(--t-meta); color: var(--ink-soft); min-height: var(--btn-h-sm);
}
.dashbar .check input { accent-color: var(--port-blue); }
/* The live count sits at the end of the row it belongs to, so "how many am I
   looking at" is answered where the filtering happens. */
.dashbar .found { margin-left: auto; font-size: var(--t-meta); color: var(--ink-faint);
                  font-variant-numeric: tabular-nums; }

/* ------------------------------------------------------- dashboard layout
   TWELVE COLUMNS, --s5 GUTTERS. Every block used to run the full width of the
   panel, so a two-column table and a thirty-bar chart were both 1,200px wide
   and neither could be compared with anything (Christian, 10 Sep: "some
   layout can be presented in multiple columns"). Spans are declared per
   block; the two breakpoints at the end of this section fold them to two
   columns and then to one. */
.dgrid {
  display: grid; grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: var(--s5); align-items: start;
}
.dgrid > .c3 { grid-column: span 3; }
.dgrid > .c5 { grid-column: span 5; }
.dgrid > .c6 { grid-column: span 6; }
.dgrid > .c7 { grid-column: span 7; }
.dgrid > .c12 { grid-column: span 12; }
/* A stack of cards inside one grid cell. Deliberately bare - no ground, no
   border, no padding - so the cards in it are never cards inside a card,
   which craft-floor.md is right to call always wrong. */
.dcol { display: flex; flex-direction: column; gap: var(--s5); min-width: 0; }

/* ONE CARD RECIPE for every block on every tab: --r-in, a --rule hairline,
   --s4 of padding, white. Elevation is declared once - a border, never a
   border under a shadow. */
.dcard {
  background: var(--white); border: 1px solid var(--rule);
  border-radius: var(--r-in); padding: var(--s4); min-width: 0;
}
/* The title is the app's own small-label recipe, in Port Blue. The line under
   it says what the figure is FOR: a number whose purpose has to be guessed is
   a number nobody acts on. */
.dcard > .u-label { color: var(--port-blue); display: block; }
.dcap { font-size: var(--t-meta); color: var(--ink-soft); line-height: 1.45;
        margin-top: var(--s1); }
.dcard > .dcap { margin-bottom: var(--s3); }
/* A card's own empty state. The panel-level .empty is a 33%-wide column of
   advice in the source list; inside a card it must fill the card. */
.dcard .empty, .dcard .loading { width: auto; min-width: 0; padding: var(--s2) 0; }
.dcard .derr { font-size: var(--t-meta); color: var(--err); padding: var(--s2) 0; }

/* A stat tile: label, value, a signed delta against a named period, and a
   30-day sparkline (dataviz marks-and-anatomy: the figure IS the form for a
   single headline number - a one-bar bar chart is not). */
/* One gap does the spacing; the parts carry no margins of their own. Four
   tiles plus the thirty-day chart have to clear the fold of a 1280x800
   laptop, and 8px of margin on two of five lines was 32px of that budget. */
.kpi { display: flex; flex-direction: column; gap: var(--s1); }
.kpin {
  font-family: var(--f-display); font-weight: 400; font-size: 30px;
  line-height: 1; color: var(--port-blue);
  /* Proportional figures, not tabular: at display size equal-width digits
     make a number like 121 read loose. Tabular is for columns. */
  font-variant-numeric: proportional-nums;
}
/* The tile's caption is a footnote at footnote size, so it sits on one line
   inside a 3-of-12 column instead of wrapping to two. */
.kpi .dcap { font-size: var(--t-micro); }
/* The trend sits BESIDE the figure, on its baseline, rather than on a line of
   its own: they are one reading of one number, and stacked they cost the tile
   30px it does not have. */
.kpitop { display: flex; align-items: flex-end; justify-content: space-between;
          gap: var(--s3); }
/* NO COLOUR ON THE DELTA, deliberately. Up is good for questions and
   documents; for refusals it is ambiguous, because a refusal is usually the
   Assistant working correctly. A green/red arrow here would assert a
   judgement the Dashboard cannot make, so the words carry the direction. */
.kpidelta { font-size: var(--t-meta); color: var(--ink-soft); }
.spark { display: block; }
.spark polyline { fill: none; stroke: var(--ink-faint); stroke-width: 1.5;
                  stroke-linejoin: round; stroke-linecap: round; }
.spark polygon { fill: var(--rule); }
/* The current day in the accent, everything behind it recessive. */
.spark circle { fill: var(--alert-orange); }

/* ONE BAR CHART, IN CSS AND INLINE SVG. A chart library for one chart is a
   dependency to install, update and explain. One series, so no legend at all:
   the card's own title names what is plotted. */
.chartwrap { display: flex; align-items: flex-start; gap: var(--s3); }
/* Both boxes are the same 132px carrying the same --s4 of head-room, so the
   axis figures line up with the plot area and the peak bar's own label has
   somewhere to sit instead of overlapping the caption above it. */
.charty {
  display: flex; flex-direction: column; justify-content: space-between;
  height: 132px; padding-top: var(--s4); flex-shrink: 0; text-align: right;
  font-size: var(--t-micro); color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}
.plot { flex: 1; min-width: 0; }
.bars { display: flex; align-items: flex-end; gap: 2px; height: 132px;
        padding-top: var(--s4); position: relative; }
/* A SOLID hairline baseline, one step off the surface. Never dashed - dashing
   reads as a projection or a threshold when it is only an axis. */
.bars::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; background: var(--rule);
}
.bar { flex: 1; min-width: 3px; height: 100%; position: relative;
       display: flex; align-items: flex-end; justify-content: center; }
.bar span {
  /* Capped rather than filling its slot, so the band keeps its air. Rounded
     at the data end, square where it meets the baseline. */
  width: 100%; max-width: 24px; min-height: 1px; background: var(--port-blue);
  border-radius: 3px 3px 0 0; position: relative;
  transition: background var(--dur-fast) var(--ease-out);
}
.bar:hover span { background: var(--alert-orange); }
/* The extreme is direct-labelled and nothing else is: a number on every bar
   is chaos and goes unread. Every other value is in the hover title and in
   the tables below, so no value is reachable only by hovering. */
.bar b {
  position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%);
  font-weight: 700; font-size: var(--t-micro); color: var(--port-blue);
  padding-bottom: 3px; white-space: nowrap;
}
/* Ticks share the bars' own flex geometry, so a label sits under its day
   rather than near it. Only Mondays carry one; thirty dates would collide. */
.barticks { display: flex; gap: 2px; margin-top: var(--s2);
            font-size: var(--t-micro); color: var(--ink-faint);
            font-variant-numeric: tabular-nums; }
.barticks span { flex: 1; min-width: 3px; text-align: center; }
.barticks .tick { color: var(--ink-soft); }
/* A count is a number AND a size. The bar is the second reading, in the
   table itself, so no separate chart is needed for the top-ten lists. */
.meter { width: 30%; min-width: 60px; }
/* min-width, so a count of 1 against a peak of 500 still draws a mark. A bar
   that rounds to nothing reads as a missing value rather than a small one. */
.meter span { display: block; height: 6px; border-radius: 3px; min-width: 3px;
              background: var(--port-blue); }

/* A definition list where the facts are pairs rather than rows - the Sources
   tab's systems column. A two-column table of two-word cells was a table
   pretending to be a list. */
.dl { display: grid; grid-template-columns: auto minmax(0, 1fr);
      gap: var(--s2) var(--s4); align-items: baseline; font-size: var(--t-data); }
.dl dt {
  font-family: var(--f-display); font-weight: 400; font-size: var(--t-label);
  letter-spacing: var(--t-label-ls); text-transform: uppercase;
  color: var(--ink-soft); white-space: nowrap;
}
.dl dd { color: var(--ink); min-width: 0; }

.mdout {
  background: var(--wash); border: 1px solid var(--rule); border-radius: var(--r-in);
  padding: var(--s4); font-size: 12px; line-height: 1.55; white-space: pre-wrap;
  max-height: 40vh; overflow: auto; font-family: ui-monospace, Menlo, monospace;
}
.path { font-family: ui-monospace, Menlo, monospace; font-size: 11.5px;
        color: var(--ink-soft); }
/* A handled report stays visible - a report that vanishes cannot be checked -
   but stops competing with the open ones. */
.faqcard.done { opacity: .6; }
/* Absent data and arriving data must not look the same (WP4: the two used to
   share .empty's own static styling with nothing to tell them apart). A thin
   rule pulses under the text, the same @keyframes the chat's own "Checking
   Portwest's records…" dot already uses, so "still arriving" reads as one
   idea across the app rather than a second animation invented for it. */
.loading { color: var(--ink-faint); font-size: 12.5px; padding: var(--s4) 0; }
.loading::after {
  content: ""; display: block; margin-top: var(--s2);
  width: 120px; max-width: 40%; height: 2px; border-radius: 1px;
  background: var(--rule); animation: pulse 1.4s ease-in-out infinite;
}

/* Table radius lives on this wrapper, not the table: a table wide enough to
   need its own horizontal scroll cannot also clip itself with overflow:hidden
   without losing that scroll, which is how the corners used to break. */
/* position: relative is load-bearing, not decoration. The trace glyphs inside
   a row carry a visually-hidden `.sr` span, which is `position: absolute`; with
   no positioned ancestor inside the wrapper its containing block was the panel
   OUTSIDE it, so `overflow: auto` could not clip it and a wide table pushed
   the whole page 65px sideways at 768px. Measured, then fixed here rather than
   at each caller. */
.tablewrap { border: 1px solid var(--rule); border-radius: var(--r-in);
             overflow: auto; position: relative; }
/* THE DASHBOARD'S TABLES, AND ONLY THOSE. These named #builderview as well
   until WP7: the only table in that view is Publisher's product table, which
   has its own recipe (.builder .btable), and the two sets fought - a
   border-top here plus a border-bottom there drew two hairlines at every row
   boundary, and vertical-align: top pulled the handle cell off the row line.
   Publisher's table now takes the parts it wants (the Port Blue header) and
   states them itself. */
/* A table inside a card takes NO border of its own - the card is the border,
   and a hairline box inside a hairline box is the ghost card. It keeps the
   radius (one step down, as every nesting level in this app does) and its own
   scroll, so a wide log scrolls inside its card and never the page. */
.dcard .tablewrap { border: 0; border-radius: var(--r-sm); }
/* The log is the one table long enough to need a header that stays put. Its
   scroll box is the wrapper, which is what makes `position: sticky` on the
   header have something to stick inside. */
.dcard.scroll .tablewrap { max-height: 62vh; }
.dcard.scroll th { position: sticky; top: 0; z-index: 1; }
#auditview table {
  width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px;
}
#auditview th {
  background: var(--port-blue); color: var(--white); text-align: left;
  /* Tightened from --s3 all round (WP8): the log is scanned, and 16px of
     leading per row put four exchanges on a screen. */
  padding: var(--s2) var(--s3); font-family: var(--f-display); font-weight: 400;
  font-size: 11px; letter-spacing: .1em; text-transform: uppercase;
  white-space: nowrap;
}
#auditview td { padding: var(--s2) var(--s3); border-top: 1px solid var(--rule-soft);
                vertical-align: top; }
#auditview td.ans { color: var(--ink-soft); }
/* The folded answer: one line of it in the summary, the whole thing under.
   No border under this .fold - it is a table cell, and the row rule already
   closes it. */
#auditview td.ans .fold { border-bottom: 0; }
#auditview td.ans summary { padding: 0; font-size: 12.5px; align-items: start; }
#auditview td.ans .full > * + * { margin-top: var(--s2); }
/* The trail's own column proportions. The two prose columns take the width;
   everything else takes what its longest value needs and no more. */
#auditview col.cw { width: 8.5em; }
#auditview col.ct { width: 6em; }
#auditview col.co { width: 8em; }
#auditview col.cq { width: 24%; }
#auditview col.cu { width: 8em; }
#auditview col.cs { width: 6em; }
#auditview col.ca { width: 28%; }
#auditview td.when { white-space: nowrap; font-variant-numeric: tabular-nums;
                     color: var(--ink-soft); }
#auditview .tierpill { font-weight: 700; text-transform: capitalize;
                       color: var(--port-blue); white-space: nowrap; }
#auditview .tag.warn { background: var(--warn-bg); border-color: var(--warn-line);
                        color: var(--warn-ink); font-weight: 700; }
#auditview caption { text-align: left; font-size: var(--t-meta);
                     color: var(--ink-soft); padding-bottom: var(--s2); }
#auditview .tag {
  display: inline-block; background: var(--wash); border: 1px solid var(--rule);
  border-radius: var(--r-sm); padding: 1px var(--s2);
  font-size: var(--t-micro); margin: 1px; color: var(--ink-soft);
}
/* The outcome, as a pill. A status colour never travels alone here: the
   refusal pill carries the warn ground AND the words "Refused - no data", so
   it is legible to a reader who cannot see the difference. */
.ocpill {
  display: inline-block; border-radius: 999px; padding: 2px var(--s2);
  background: var(--wash); border: 1px solid var(--rule); color: var(--ink-soft);
  font-family: var(--f-display); font-weight: 400; font-size: var(--t-micro);
  letter-spacing: .08em; text-transform: uppercase; white-space: nowrap;
}
.ocpill.warn { background: var(--warn-bg); border-color: var(--warn-line);
               color: var(--warn-ink); }
.ocpill.on { background: var(--white); border-color: var(--port-blue);
             color: var(--port-blue); }
/* A style code inside a logged question, marked so the eye finds it - this is
   a log searched BY style code. Port Blue on the wash, not the Alert Orange
   the app gives a code elsewhere: orange measures 2.9:1 on white and this is
   12.5px body text, not a 20px display code. */
.hl { color: var(--port-blue); font-weight: 700; background: var(--wash);
      border-radius: var(--r-sm); padding: 0 3px; }

/* ============================================================ adaptation
   Desktop-first: this is demoed on a laptop. Narrow is handled so an iPad
   works, because someone will open it on one. */

@media (max-width: 1100px) {
  main { flex-direction: column; }
  .chatcol { flex: 2; }
  .sourcecol { flex: 1; }
  .masthead { gap: var(--s4); }
  /* The Dashboard folds four columns to two and everything wider to one. A
     half-width bar chart at 900px is thirty bars in 320px, which is a smear;
     a half-width table of questions is two words a line. */
  .dgrid > .c3 { grid-column: span 6; }
  .dgrid > .c5, .dgrid > .c6, .dgrid > .c7 { grid-column: span 12; }
}

/* Below this the columns stack, and the shell stops being a fixed-height app
   frame: it scrolls. Measured at 375px, the old fixed frame overflowed .chatcol
   by 49px INSIDE `overflow: hidden`, so the composer and Send button were cut
   in half with no way to reach them. A scrolling page cannot clip a control. */
@media (max-width: 900px) {
  body { height: auto; min-height: 100dvh; }
  main { flex-direction: column; }
  .chatcol, .sourcecol { flex: none; }
  .chatcol .panel, .chatcol, .sourcecol { overflow: visible; }
  #chat { overflow-y: visible; min-height: 40vh; }
  .sourcecol { overflow-y: visible; }
  /* The Dashboard and Publisher panels scroll with the page here rather than
     inside themselves: a 450px scroll box nested in an 800px scrolling page
     is two scrollbars for one list. Tables keep their own scroll, which is
     the one place a nested one is right. */
  #auditview, #builderview { overflow: visible; }
  /* The composer is the one thing that must never scroll away on a phone. */
  .composer { position: sticky; bottom: 0; z-index: 2;
              box-shadow: 0 -6px 16px rgba(0, 62, 82, .06); }
}

@media (max-width: 720px) {
  body { padding: var(--s2); gap: var(--s2); }
  #chat, .chips, .composer, .sourcecol { padding-left: var(--s4);
                                         padding-right: var(--s4); }
  .masthead { padding: var(--s3) var(--s4); }
  /* Symmetric padding here (both s3), so the content-box correction above
     isn't needed - 50% is already the lockup's centre. */
  .masthead .navicon { top: 50%; }
  /* The lockup is 348px wide at its 21.6px cap height - more than a 375px
     phone leaves after the body and masthead padding (327px), so the layout
     viewport was zooming out to 404px to fit it. Let it shrink with the row
     instead: at 375px it sets at about 20px, still legible. Above 720px the
     rule at .brand img stands untouched. */
  .brand { min-width: 0; flex: 0 1 auto; }
  .brand img { height: auto; width: 100%; max-width: 348px; }
  /* Two competing needs: 44px touch targets, and a chrome bar that doesn't eat
     a third of a phone screen. Wrapping gave 44px targets across three rows
     (~300px of an 812px viewport). One scrolling row keeps both. */
  .utility { flex-wrap: nowrap; overflow-x: auto; align-items: center;
             gap: var(--s2); padding-top: var(--s1); padding-bottom: var(--s1);
             scrollbar-width: none; }
  .utility::-webkit-scrollbar { display: none; }
  .utility button { min-height: 44px; }
  .utility .spacer { flex: 0 0 var(--s2); }
  /* One column, and the gutters come in a step - at 375px a 24px gutter is
     6% of the viewport spent on air between single-column cards. */
  .dgrid { gap: var(--s3); }
  .dcol { gap: var(--s3); }
  .dgrid > .c3, .dgrid > .c5, .dgrid > .c6, .dgrid > .c7 { grid-column: span 12; }
  #auditview, #builderview { padding: var(--s4); }
}

/* Not a courtesy: this is a safety company's tool and vestibular disorders are
   real. Every transform-based animation collapses to nothing. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important; animation-delay: 0ms !important;
    transition-duration: 1ms !important;
    /* Without this the 1ms duration applies to an `infinite` animation and the
       thinking dot spins ~1000 times a second forever - a blanket kill that
       costs more than it saves. One iteration, then rest. */
    animation-iteration-count: 1 !important;
  }
  /* The dot is the only "still working" affordance, so it keeps a non-motion
     one: a steady half-strength state rather than a stopped pulse. Same
     reasoning for .loading's rule (WP4). */
  .thinking::before, .loading::after { opacity: .55; }
}

/* ============================================================ tier chooser
   Demo mode's front door: same shell as the password page, but the choice is
   which access level to walk through rather than a credential. See CHOOSE_PAGE
   and the ALLOW_TIER_SWITCH gate in server.py. */

.signin { flex: 1; display: flex; align-items: center; justify-content: center;
          min-height: 0; overflow-y: auto; padding: var(--s5) 0; }

.choose { width: 720px; max-width: 100%; }
.choose h1 {
  font-family: var(--f-display); font-weight: 400; font-size: 24px;
  letter-spacing: .06em; text-transform: uppercase; color: var(--port-blue);
}
.choose .sub { font-size: var(--t-body); color: var(--ink-soft);
               margin: var(--s3) 0 var(--s5); }
.choose .choices { display: flex; gap: var(--s3); align-items: stretch; }
.choose .note { font-size: var(--t-meta); color: var(--ink-soft);
                line-height: 1.6; margin-top: var(--s5); }

.choice {
  flex: 1; display: flex; flex-direction: column; gap: var(--s2);
  text-align: left; background: var(--white); border: 1px solid var(--rule);
  border-radius: var(--r); padding: var(--s5); box-shadow: var(--lift);
}
/* Deliberately overdone compared with the rest of the app's 120ms nudges:
   this is a three-way choice on a near-empty page, and the card should feel
   like it comes forward to meet the cursor. Still transform + shadow only, so
   it stays on the compositor. */
.choice:hover {
  border-color: var(--alert-orange);
  transform: translateY(-6px) scale(1.02);
  box-shadow: 0 18px 44px rgba(0, 62, 82, .18);
}
.choice:hover .ch-name { color: var(--alert-orange); }
.choice:active { transform: translateY(-2px) scale(1.005); }
.choice { transition: border-color var(--dur-mid) var(--ease-out),
                      transform var(--dur-mid) var(--ease-out),
                      box-shadow var(--dur-mid) var(--ease-out); }
.ch-name { transition: color var(--dur-mid) var(--ease-out); }
.ch-name {
  font-family: var(--f-display); font-weight: 400; font-size: 17px;
  letter-spacing: .06em; text-transform: uppercase; color: var(--port-blue);
}
.ch-who { font-size: 12px; color: var(--ink); }
.ch-sees { font-size: 12px; color: var(--ink-soft); line-height: 1.5;
           flex: 1; }
/* What this tier is in production - the line that turns a demo toggle into a
   statement about the real system. */
.ch-prod {
  font-family: var(--f-display); font-weight: 400; font-size: var(--t-micro);
  letter-spacing: .1em; text-transform: uppercase; color: var(--alert-orange);
  padding-top: var(--s3); position: relative;
}
.ch-prod::before {
  content: ""; position: absolute; left: 0; right: 0; top: 0;
  height: 1px; border-radius: 1px; background: var(--rule);
}

@media (max-width: 720px) {
  .choose .choices { flex-direction: column; }
  .ch-sees { flex: none; }
}

/* ============================================================ comparison
   Two or more products in one answer become a table rather than a stack of
   separate blocks: the point of a comparison is reading ACROSS, and stacked
   cards force the reader to hold each row in their head. Standards run down
   the side, products across the top, so an empty cell is itself the answer to
   "which of these is certified to that?".

   Built from the records in app.js, exactly like .certblock - never from prose. */

/* Same quieting as .certblock: a light card, not a Port Blue slab. */
/* FLAT (Christian, 3 Sep). The gradient is gone, and with it the reason the
   sticky column and the striped rows each needed their own copy of it - three
   painted surfaces stacked on one table was most of why it read as busy. */
.compare {
  align-self: flex-start; width: 100%;
  background: var(--white);
  color: var(--ink);
  border: 1px solid var(--rule); border-radius: var(--r-in);
  padding: var(--s4) var(--s5) var(--s5); overflow-x: auto;
}
/* A <details> now, and it opens only when the QUESTION asked for a comparison
   (see wants_comparison, server side). Collapsed it is one line saying what it
   holds - "7 standards across 4 products" - which is the honest summary of a
   surface too big to drop into a conversation unasked. Same rules as the
   certification tile beside it, so the two fold the same way. */
.compare > summary {
  display: flex; align-items: baseline; gap: var(--s3); cursor: pointer;
  list-style: none; padding-bottom: var(--s3); margin-bottom: var(--s2);
  position: relative;
}
.compare > summary::-webkit-details-marker { display: none; }
.compare > summary::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; border-radius: 1px; background: var(--rule);
}
.compare > summary:hover h3 { color: var(--port-blue); }
.compare > summary:focus-visible { outline: 2px solid var(--port-blue); outline-offset: 2px; }
/* Orange title (Christian, 3 Sep), with the count kept as a quiet caption so
   there is still only one thing shouting in the header. */
.compare > summary h3 {
  font-family: var(--f-display); font-weight: 400; margin: 0;
  font-size: var(--t-h2); letter-spacing: var(--t-h2-ls); text-transform: uppercase;
  color: var(--alert-orange);
  transition: color var(--dur-fast) var(--ease-out);
}
.compare > summary .count {
  font-family: var(--f-body); font-weight: 400; font-size: var(--t-meta);
  letter-spacing: .04em; color: var(--ink-faint); margin-left: auto;
}
.compare table { width: 100%; border-collapse: separate; border-spacing: 0; }
.compare th, .compare td {
  text-align: left; padding: var(--s3) var(--s4);
  vertical-align: top; font-size: var(--t-data); position: relative;
}
/* STANDARD aligns to the top of the header cell, level with the style codes.
   Bottom-aligned it sat level with the wrapped product NAMES instead, which
   put the column's label a line or two below the row it labels. */
.compare thead th:first-child { vertical-align: top; }
/* Display face (Geom Graphic) ships 400 only - a declared 600 here was never
   rendering as anything but 400 anyway (WP4). STANDARD (thead th:first-child)
   used to repeat this whole block a second time with the same values, "so it
   is set like the codes beside it" - true, because it's the SAME rule; the
   second copy is gone. */
.compare thead th {
  font-family: var(--f-display); font-weight: 400; text-transform: uppercase;
  font-size: var(--t-h3); letter-spacing: .04em; color: var(--port-blue);
  white-space: nowrap; vertical-align: bottom;
  /* Per CELL, not on the row: the first header cell is sticky, and a border
     set on <tr> scrolls out from under it. */
  border-bottom: 1px solid var(--rule);
}
/* Wider measure and tighter leading: at 24ch a three-word product name broke
   over three lines and pushed the codes apart. */
.compare thead th span {
  display: block; font-family: var(--f-body); font-size: var(--t-meta);
  letter-spacing: 0; color: var(--ink-soft); text-transform: none;
  white-space: normal; max-width: 30ch; margin-top: 3px; font-weight: 400;
  line-height: 1.3;
}
/* The standard column is STICKY. With eighteen products the table is far
   wider than the panel, and scrolling right used to take the row's own label
   off screen - so a reader was comparing classes without being able to see
   which standard they belonged to. */
.compare tbody th, .compare thead th:first-child {
  position: sticky; left: 0; z-index: 1;
  color: var(--port-blue); padding-right: var(--s5);
  white-space: nowrap;
  /* Opaque, and the same ground as the panel: a sticky column needs its own
     background or the values scroll visibly under it. */
  background: var(--white);
}
/* tbody th (a standard's name, e.g. "EN ISO 20471") is body face, which DOES
   have a 700 - a declared 600 was quietly rendering as 700 already, since the
   browser's own weight-matching climbs to the nearest heavier cut when the
   exact one is missing. Named honestly rather than left to fall back. */
.compare tbody th { font-weight: 700; }
/* The sticky column needs its own right edge, or values scroll under it with
   nothing to say where the column ends. */
.compare tbody th::before, .compare thead th:first-child::before {
  content: ""; position: absolute; top: 0; bottom: 0; right: calc(var(--s4) / 2);
  width: 1px; background: var(--rule);
}
/* Alternate rows, tinted rather than ruled: at this width a horizontal rule
   every row turned the table into a grid of boxes, and the eye needs to track
   ACROSS a row more than it needs to know where one ends. */
/* A translucent tint, so the panel's gradient still shows through the striped
   rows instead of being painted over by a flat grey. */
/* Fainter (Christian, 3 Sep), and the band is rounded at both ends so a
   tinted row reads as one object rather than a full-width slab. */
/* .tint, not nth-child: the certification band and the specification band
   each count their OWN rows from zero, so the row right under either heading
   is always white and the two bands cannot throw each other's stripe out of
   step (Christian, 3 Sep). */
.compare tbody tr.tint th,
.compare tbody tr.tint td { background: rgba(0, 62, 82, .025); }
.compare tbody tr.tint th:first-child {
  border-top-left-radius: var(--r-sm); border-bottom-left-radius: var(--r-sm);
}
.compare tbody tr.tint td:last-child {
  border-top-right-radius: var(--r-sm); border-bottom-right-radius: var(--r-sm);
}
/* No per-row rule: the tint already separates the rows, and doing both drew a
   grid over a table that is meant to be read across (Christian, 1 Sep). */
/* THE RATING IS DATA, SO IT IS SET IN THE BODY FACE (Christian, 1 Sep).
   In Geom Graphic, uppercase and letter-spaced, a class read louder than the
   product code above it and the standard beside it - the table's own headings
   outranked by its cells. DIN Bold keeps the emphasis without the shouting,
   and restores the order: product code, then standard, then value. */
.compare .val {
  color: var(--port-blue); font-family: var(--f-body);
  font-weight: 700; letter-spacing: .01em;
}
/* An empty cell is a finding, not a rendering gap - say which it is. But it is
   the most repeated string in the table, so it is also the quietest: the eye
   should land on what a product IS certified to. */
.compare .none {
  color: var(--ink-faint); font-size: var(--t-meta);
  font-variant-numeric: tabular-nums;
}
/* Only shown where a class genuinely varies with size - "all sizes" is stated
   once below the table instead. That is what makes the orange chip mean
   something again: it now appears on the handful of rows a reader must not
   skim past, rather than on most of them. */
/* Also the body face. These are measured VALUES - "ELIM 32 CAL/CM2", "WP
   20,000MM" - and in Geom Graphic they read as louder than the class they
   qualify, which is the same inversion the ratings had. Missed on the first
   pass because they share a class with the size qualifier (Christian, 1 Sep). */
.compare .scope {
  display: block; margin-top: var(--s1);
  font-family: var(--f-body); font-weight: 400;
  font-size: var(--t-meta); letter-spacing: .01em;
  color: var(--ink-faint);
}
/* The size qualifier keeps the display face and the Alert Orange field: it is
   the one thing in this table a reader must not skim past. */
.compare .scope.cond {
  font-family: var(--f-display); font-size: var(--t-label);
  letter-spacing: var(--t-label-ls); text-transform: uppercase;
}
/* Outlined, not filled, and set in the body face like the class it follows
   (Christian, 1 Sep). A solid orange slab at label size was heavier than the
   value it qualifies and broke the line it sits on; an outline still stops a
   skim without shouting. Inline with a space before it, so "Class 2" and its
   qualifier read as one phrase rather than two objects. */
.compare .scope.cond {
  color: var(--alert-orange); background: none;
  border: 1px solid var(--alert-orange);
  display: inline; padding: 1px 5px; border-radius: var(--r-sm);
  /* Body face DOES have a 700 - a declared 600 was already rendering as it. */
  font-family: var(--f-body); font-weight: 700; font-size: var(--t-meta);
  letter-spacing: .01em; text-transform: none; white-space: nowrap;
  margin-left: 5px;
}
/* Several rows under one standard need to stack rather than run together. */
.compare .crow { display: block; margin-bottom: 3px; }
.compare .crow:last-child { margin-bottom: 0; }
.compare .prov {
  margin-top: var(--s4); font-size: var(--t-micro); letter-spacing: .03em;
  color: var(--ink-soft);
}

/* ---- the specification band --------------------------------------------
   Below the certifications and labelled, because these are a different kind
   of fact. "Not certified" is a finding about protection; "Not recorded" is a
   gap in the record, and the two must not be read as the same thing. */
/* No rule of its own - the alternating band does the separating now, and a
   second line drawn on top of it was a hairline sitting inside a tint
   (Christian, 3 Sep). Extra padding still marks the join. */
/* A rule of its own after all, under the SPECIFICATION word itself - not a
   line across the row, which would fight the white-then-stripe reset below
   it (Christian, 3 Sep). */
.compare .bandhead th, .compare .bandhead td {
  padding-top: var(--s6); padding-bottom: var(--s3);
}
.compare .bandhead th, .compare .bandhead td {
  border-bottom: 1px solid var(--rule);
}
/* Set exactly like STANDARD in the head above it: both are the label for the
   column of row labels, so they are the same kind of word. */
.compare .bandhead th {
  font-family: var(--f-display); font-weight: 400; text-transform: uppercase;
  font-size: var(--t-h3); letter-spacing: .04em;
  color: var(--port-blue);
}
/* One material per LINE. A comma-separated string of three fabric brands read
   as one long name (Christian, 3 Sep). */
.compare .fabline { display: block; }
.compare .fabline + .fabline { margin-top: 3px; }
/* A brand's own mark where the PIM holds one. Capped by height so a 755x296
   logo and a squarer one still sit on the same line. */
.compare .fablogos { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
/* 150% of label height by default - 36px - because these are wordmarks, not
   icons: "Portwest Extreme Waterproof" is 755x296 and was unreadable at 15px.
   Shrinks to 24px past four products, where the columns need the width back
   (Christian, 3 Sep) - --fabicon-h is set once per table, in JS, off the
   product count. max-width keeps a very wide logo from setting the column. */
.compare .fablogos img {
  height: var(--fabicon-h, 36px); width: auto; max-width: 30ch; display: block;
}
/* The care symbols, at label height and on a baseline with the row. */
.compare .caresyms { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.compare .caresyms img { height: 16px; width: auto; display: block; }
/* Set exactly like .val above - same weight, same colour, same size - so the
   two bands read as one table rather than a table with a footnote stapled to
   it (Christian, 3 Sep). Was smaller and grey; nothing here outranks a
   certification, but nothing should read as lesser than one either. */
.compare .specval {
  color: var(--port-blue); font-family: var(--f-body);
  font-weight: 700; letter-spacing: .01em;
  line-height: 1.5;
}
/* The fabric row is the tall one and is never shortened - a truncated
   specification is a wrong specification - so it wraps to a sane measure
   instead of stretching the column. */
.compare .specrow td { white-space: normal; }
.compare .specrow .specval { max-width: 34ch; }
.compare .specrow .swatches { margin-bottom: 3px; }

/* HISTORY, in the Publisher view. One row per project - what it is, how big
   it is, how many times it has been built - opening to its own builds and the
   two things you can do with the project itself. The row is the app's standard
   <details>, with the same chevron every other disclosure uses. */
.blist { display: flex; flex-direction: column; gap: var(--s2); }
.hrow {
  background: var(--white); border: 1px solid var(--rule);
  border-radius: var(--r-in);
}
.hrow > summary {
  display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap;
  padding: var(--s3) var(--s4); cursor: pointer; list-style: none;
}
.hrow > summary::-webkit-details-marker { display: none; }
.hrow > summary:focus-visible { outline: 2px solid var(--port-blue); outline-offset: 2px; }
.hrow .hname { color: var(--port-blue); font-weight: 700; }
.hrow .hmeta { font-size: var(--t-meta); color: var(--ink-soft); margin-left: auto; }
.hrow .hbody {
  padding: 0 var(--s4) var(--s3); display: flex; flex-direction: column;
  gap: var(--s2);
}
.hrow .hbuild {
  display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap;
  padding-top: var(--s2); border-top: 1px solid var(--rule-soft);
}
.hrow .hwhen { font-size: var(--t-meta); color: var(--ink-soft); margin-right: auto; }
.hrow .hacts {
  display: flex; align-items: center; gap: var(--s3); flex-wrap: wrap;
  padding-top: var(--s3); border-top: 1px solid var(--rule-soft);
}
.hrow .hmsg { font-size: var(--t-meta); color: var(--ink-soft); }
.hrow .btn { min-height: var(--btn-h-sm); padding: 0 var(--s4); font-size: var(--t-micro);
             letter-spacing: var(--btn-ls); border-width: 1px; }
.hrow .bdrop {
  border: none; background: none; cursor: pointer; font-size: 17px; line-height: 1;
  color: var(--ink-faint); padding: 4px 6px; border-radius: var(--r-sm);
  margin-left: auto;
}
.hrow .bdrop:hover { color: var(--alert-orange); background: var(--wash); }
#builderview #bviewlists { margin-top: var(--s6); }
#builderview #bviewlists .subhead { margin-top: var(--s6); }

/* ============================================================ flyer builder
   The form that collects everything a flyer needs beyond the product records.
   Built on the same surfaces as the rest of the transcript - the compare
   table's panel, the composer's field, the document card's button - because a
   form that looks like a different application is a form people distrust with
   their own contact details.

   It posts straight to the renderer, so what is typed here is what prints. */
.builder {
  align-self: flex-start; width: 100%;
  background: linear-gradient(to top, var(--wash), var(--white));
  border: 1px solid var(--rule); border-radius: var(--r-in);
  padding: var(--s4) var(--s5) var(--s5);
}
.builder .hd {
  display: flex; align-items: baseline; gap: var(--s3);
  padding-bottom: var(--s3); margin-bottom: var(--s3); position: relative;
}
.builder .hd::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; border-radius: 1px; background: var(--rule);
}
.builder .hd h3 {
  font-family: var(--f-display); font-weight: 400; font-size: var(--t-h2);
  letter-spacing: var(--t-h2-ls); text-transform: uppercase;
  color: var(--alert-orange);
}
.builder .hd .count {
  font-family: var(--f-display); font-size: var(--t-data); letter-spacing: .06em;
  color: var(--port-blue); margin-left: auto; text-transform: uppercase;
}
/* Said once, plainly: nothing here is required. A form of empty fields with no
   note reads as a wall of work before you get your document. */
.builder .bhint { font-size: var(--t-meta); color: var(--ink-soft); margin-bottom: var(--s4); }

/* The page switches, on the line under the hint: they belong with "everything
   here is optional", being the two things a document has whether or not
   anyone fills a field in. */
.builder .bopts { display: flex; flex-wrap: wrap; gap: var(--s4); margin-bottom: var(--s4); }
.builder .bopt {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: var(--t-meta); color: var(--ink-soft); cursor: pointer;
}
.builder .bopt input { accent-color: var(--port-blue); }
/* An inline-flex box ignores the hidden attribute, which is how the
   booklet-only switch showed up on a flyer. */
.builder .bopt[hidden] { display: none; }
/* Cover is disabled until a logo exists (WP4) - it used to stay tickable with
   only a tooltip explaining that ticking it did nothing. The label greys with
   its checkbox rather than being left the only readable part of a dead control. */
.builder .bopt:has(input:disabled) { color: var(--ink-faint); cursor: default; }

.builder .bgrid {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s3); margin-bottom: var(--s4);
}
.builder .bfield { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
/* The four fields the back page prints, dimmed when the back page is off -
   the same .45 an unticked colourway takes, so "not in this document" looks
   the same wherever it is said. Values are kept, so ticking it back on costs
   nobody their own address twice. */
.builder .bfield.off { opacity: .45; pointer-events: none; }
/* Under the row of switches rather than under one of them: the row is a line
   of labels and a hint inside one would set it two lines taller than its
   neighbours. */
.builder .bbackhint { margin-top: calc(var(--s4) * -1 + 2px); }
.builder .bfield.wide { grid-column: span 2; }
.builder .bfield span {
  font-family: var(--f-display); font-size: var(--t-label);
  letter-spacing: var(--t-label-ls); text-transform: uppercase; color: var(--ink-soft);
}
.builder input[type="text"], .builder .bfield input:not([type="file"]), .builder .bprice {
  font: inherit; font-size: 13px; color: var(--ink);
  background: var(--white); border: 1px solid var(--rule);
  border-radius: var(--r-sm); padding: 7px var(--s3); min-width: 0;
}
.builder .bfield input:focus-visible, .builder .bprice:focus-visible {
  outline: 2px solid var(--port-blue); outline-offset: 1px; border-color: transparent;
}
/* THE FILE INPUT IS PRESENT, NOT REPLACED: hidden inside its own label, so
   the keyboard still tabs to it and a screen reader still announces "Choose
   logo, file upload". Only the browser's own grey button is gone. */
.builder .blogorow { display: flex; align-items: center; gap: var(--s2); min-width: 0; }
.builder .bpick {
  min-height: var(--btn-h-sm); padding: 0 var(--s4); font-size: var(--t-micro);
  letter-spacing: var(--btn-ls); border-width: 1px; flex-shrink: 0; cursor: pointer;
}
.builder .bpick input[type="file"] {
  position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;
}
.builder .bpick:focus-within { outline: 2px solid var(--port-blue); outline-offset: 2px; }
.builder .blogoname {
  font-size: var(--t-meta); color: var(--ink-soft); overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap;
}
.builder .blogodrop {
  flex-shrink: 0; border: 0; background: none; padding: 2px 4px; cursor: pointer;
  font-size: var(--t-meta); color: var(--ink-faint); text-decoration: underline;
}
.builder .blogodrop:hover { color: var(--err); }
.builder .blogodrop:focus-visible { outline: 2px solid var(--port-blue); outline-offset: 1px; }

/* Products per page. A segmented control rather than a select: three options,
   and each one is a shape you recognise faster than you read. Disabled where
   the layout needs more products than the document holds - the server would
   refuse it anyway, and being told before the click is cheaper. */
.builder .blayouts {
  display: flex; gap: 6px; margin-bottom: var(--s4); flex-wrap: wrap;
}
.builder .blay {
  display: inline-flex; align-items: center; gap: 7px;
  font: inherit; font-family: var(--f-display); font-size: var(--t-micro);
  letter-spacing: var(--btn-ls); text-transform: uppercase;
  color: var(--ink-soft); background: var(--white);
  border: 1px solid var(--rule); border-radius: var(--r-sm);
  min-height: var(--btn-h-sm); padding: 0 11px; cursor: pointer;
}
.builder .blay svg { width: 20px; height: 15px; display: block; }
.builder .blay svg rect { fill: currentColor; opacity: .34; }
.builder .blay:hover:not(:disabled) { border-color: var(--alert-orange); color: var(--ink); }
.builder .blay.on {
  color: var(--port-blue); border-color: var(--port-blue); background: var(--wash);
}
.builder .blay.on svg rect { opacity: 1; fill: var(--alert-orange); }
.builder .blay:disabled { opacity: .4; cursor: not-allowed; }
.builder .blay:focus-visible { outline: 2px solid var(--port-blue); outline-offset: 1px; }

/* Add another product. The list is short and dismisses itself on a pick, so it
   never sits over the table it is adding to. */
.builder .badd { position: relative; margin-top: var(--s3); }
.builder .badd input {
  font: inherit; font-size: 13px; color: var(--ink); width: 100%;
  background: var(--white); border: 1px solid var(--rule);
  border-radius: var(--r-sm); padding: 7px var(--s3);
}
.builder .badd input:focus-visible {
  outline: 2px solid var(--port-blue); outline-offset: 1px; border-color: transparent;
}
.builder .bresults {
  position: absolute; z-index: 5; left: 0; right: 0; top: calc(100% + 4px);
  background: var(--white); border: 1px solid var(--rule);
  border-radius: var(--r-sm); box-shadow: 0 8px 22px -12px rgba(0, 40, 55, .4);
  max-height: 260px; overflow-y: auto;
}
.builder .bhit {
  display: flex; align-items: center; gap: var(--s3); width: 100%;
  font: inherit; font-size: 13px; text-align: left; color: var(--ink);
  background: none; border: 0; border-bottom: 1px solid var(--rule-soft);
  padding: 7px var(--s3); cursor: pointer;
}
.builder .bhit:last-child { border-bottom: 0; }
.builder .bhit:hover, .builder .bhit:focus-visible { background: var(--wash); }
.builder .bhit img, .builder .bhit > span:first-child {
  width: 26px; height: 26px; object-fit: contain; flex-shrink: 0;
}
.builder .bhit b {
  font-family: var(--f-display); color: var(--alert-orange);
  font-size: 12px; letter-spacing: .06em; flex-shrink: 0;
}
.builder .bnohit { padding: 9px var(--s3); font-size: 12.5px; color: var(--ink-soft); }
/* The waiting line in the results box: the chat's own pulse, so a slow PIM
   search looks like the assistant thinking rather than like nothing. */
.builder .bpulse { display: flex; align-items: center; gap: var(--s2); }
/* What a pasted list could not resolve, named under the input and left there
   until the next search - a code nobody can find is the one thing about a
   paste that must not scroll away. */
.builder .bmulti {
  margin-top: var(--s2); font-size: var(--t-meta); color: var(--warn-ink);
}
.builder .bwhich {
  padding: 7px var(--s3) 4px; font-size: var(--t-meta); color: var(--ink-soft);
  border-bottom: 1px solid var(--rule-soft);
}
.builder .bwhich b { color: var(--alert-orange); font-family: var(--f-display); }

/* The radius lives on the wrapper, as it does on .tablewrap: the header band
   is the top of the panel, and a square corner on it was the one edge in the
   app that did not match the rest (WP7 task 3). overflow-x stays auto for a
   narrow window; y is hidden, which is what actually clips the corners. */
.builder .btablewrap {
  overflow: hidden; overflow-x: auto; border-radius: var(--r-in);
}
.builder .btable { width: 100%; border-collapse: separate; border-spacing: 0; }
/* PORT BLUE IN BOTH HOSTS. The Publisher view used to get this band by
   accident - a Dashboard table rule keyed on #builderview reached the product
   table - so the same component read as a dark-headed table in the view and a
   light-headed one in the chat card. It is the Dashboard's own recipe, stated
   here on purpose, and the accident is removed (see #auditview below). */
.builder .btable th {
  /* No left padding, so a column heading sits over its own cells - the body
     rows carry none either. */
  text-align: left; padding: var(--s2) var(--s3) var(--s2) 0;
  background: var(--port-blue); color: var(--white);
  font-family: var(--f-display); font-weight: 400; font-size: var(--t-label);
  letter-spacing: var(--t-label-ls); text-transform: uppercase;
  white-space: nowrap;
}
.builder .btable td {
  padding: var(--s3) var(--s3) var(--s3) 0; vertical-align: middle;
  border-bottom: 1px solid var(--rule-soft); font-size: 13px;
}
/* A drawn handle rather than a text glyph. At 15px the character was barely
   visible and gave no hint it could be dragged.

   THE CELL STAYS A CELL. `display: flex` on the <td> took it out of the row
   box: its own borders were drawn around a 47px flex box inside a 66px row,
   which is the rule sitting off the row line (WP7 task 4), and it squeezed the
   glyph to 8px wide. The flex box is .bgrip, inside the cell. */
.builder .bhandle {
  cursor: grab; user-select: none; padding: var(--s2) var(--s2) var(--s2) 0;
  width: 46px;
}
.builder .bgrip { display: flex; align-items: center; gap: 2px; }
/* Sized to the arrow stack beside it (21px), and the viewBox is cropped to the
   bars themselves so 20px of box is 20px of ink rather than 12 (WP7 task 5). */
.builder .bhandle svg {
  width: 22px; height: 19px; display: block; fill: var(--ink-faint);
  transition: fill var(--dur-fast) var(--ease-out);
}
.builder .bhandle:hover svg { fill: var(--port-blue); }
.builder .bhandle:active { cursor: grabbing; }
/* Reordering was drag-only, which is no path at all for a keyboard. Two
   buttons, the same move one row at a time.

   .bmove and .bdel (below) sit outside the app's three button sizes on
   purpose (WP4): they are glyphs sized to a 40px table row, not calls to
   action, and stacking two arrows at CTA scale would overflow the row they
   move within. */
.builder .bmove { display: flex; flex-direction: column; gap: 1px; }
.builder .bmove button {
  font: inherit; font-size: 8px; line-height: 1; cursor: pointer;
  color: var(--ink-faint); background: none; border: 0; padding: 1px 2px;
}
.builder .bmove button:hover:not(:disabled) { color: var(--alert-orange); }
.builder .bmove button:disabled { opacity: .3; cursor: default; }
.builder .bmove button:focus-visible {
  outline: 2px solid var(--port-blue); outline-offset: 1px;
}
.builder .bcode {
  font-family: var(--f-display); color: var(--alert-orange);
  letter-spacing: .05em; white-space: nowrap;
}
.builder .bname { color: var(--port-blue); font-weight: 700; min-width: 18ch; }  /* was 600: no such weight in din-2014, already rendered as 700 */
/* SAID BEFORE THE BUILD, not discovered after it. A record with no product
   shot in the DAM (A670 today) prints a blank where the photograph goes, and
   Publisher's own top complaint was images silently missing. The build is
   still allowed - the person just knows. */
.builder .bwarn {
  display: block; font-weight: 400; font-size: 11px; color: var(--warn-ink);
  text-transform: none; letter-spacing: 0; margin-top: 2px;
}
.builder .bthumb {
  width: 40px; height: 40px; object-fit: contain; display: block;
  background: var(--white); border-radius: var(--r-sm);
}
/* A colourway is picked by clicking the swatch itself, so the control is the
   thing it controls. The native checkbox is kept for the keyboard and for
   screen readers, and hidden visually rather than replaced. */
.builder .bcols { display: flex; flex-wrap: wrap; gap: var(--s2); }
.builder .bcol {
  display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
  padding: 3px var(--s2) 3px 3px; border-radius: 999px;
  border: 1px solid var(--rule); background: var(--white);
  font-size: 11px; color: var(--ink-faint);
  transition: border-color var(--dur-fast) var(--ease-out),
              color var(--dur-fast) var(--ease-out);
}
.builder .bcol input { position: absolute; opacity: 0; width: 0; height: 0; }
.builder .bcol .swimg, .builder .bcol > span:not(.bcolname) {
  width: 14px; height: 14px; border-radius: 50%;
  flex-shrink: 0; display: block; overflow: hidden;
}
.builder .bcol .swimg img {
  width: 116%; height: 116%; margin: -8%; object-fit: cover; display: block;
}
.builder .bcol .swimg.missing img { display: none; }
.builder .bcol > span:not(.bcolname), .builder .bcol .swimg.missing {
  border: 1.5px solid var(--ink-faint); background: none;
}
.builder .bcol.on { border-color: var(--port-blue); color: var(--port-blue); }
.builder .bcol:not(.on) { opacity: .45; }
.builder .bcol input:focus-visible + .swimg,
.builder .bcol:focus-within { outline: 2px solid var(--port-blue); outline-offset: 1px; }
.builder .bnone { font-size: 11px; color: var(--ink-faint); }
/* One field, visually - the currency symbol reads as part of the input
   rather than a label beside it, so what a reader sees matches what gets
   sent: "$" or "€", never both. */
.builder .bpricewrap {
  display: inline-flex; align-items: center; gap: 5px;
  border: 1px solid var(--rule); border-radius: var(--r-sm);
  background: var(--white); padding: 0 0 0 8px;
}
.builder .bpricewrap:focus-within {
  outline: 2px solid var(--port-blue); outline-offset: 1px; border-color: transparent;
}
.builder .bprefix {
  font-family: var(--f-display); font-size: 12px; color: var(--ink-soft);
  flex-shrink: 0; user-select: none;
}
.builder .bprice {
  width: 9ch; border: none; background: none; padding: 7px 8px 7px 0;
}
.builder .bprice:focus-visible { outline: none; }

/* The currency toggle, in the Price column's own header - the nearest point
   of use, and the one place a reader would think to look for it, since a
   flyer is priced in one currency at a time. */
.builder .bpricehd {
  display: inline-flex; align-items: center; gap: var(--s2);
}
.builder .bcur {
  /* font: inherit alone picked up <th>'s UA-default bold through the chain -
     the select rendered bold with nothing in this file asking for it
     (Christian, WP4). Weight is set explicitly now. */
  font: inherit; font-weight: 400; font-family: var(--f-display); font-size: 11px;
  text-transform: none; color: var(--port-blue); background: var(--white);
  border: 1px solid var(--rule); border-radius: var(--r-sm); padding: 2px 4px;
  cursor: pointer;
}
.builder .bcur:hover { border-color: var(--alert-orange); }
.builder .bcur:focus-visible { outline: 2px solid var(--port-blue); outline-offset: 1px; }
/* A suffix currency sits after the number, because that is how the market
   writes it: 12 990 Ft, never Ft 12 990. */
.builder .bsuffix { padding-right: 8px; }
.builder .bdel {
  border: none; background: none; cursor: pointer; font-size: 17px;
  line-height: 1; color: var(--ink-faint); padding: 4px 6px; border-radius: var(--r-sm);
}
.builder .bdel:hover { color: var(--err); background: var(--err-bg); }
.builder .bdel:focus-visible { outline: 2px solid var(--err); outline-offset: 1px; }

.builder {
  /* The document card appears directly beneath the builder once it is built,
     and the two were touching. */
  margin-bottom: var(--s4);
}
.builder .bfoot {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s3) var(--s4);
  margin-top: var(--s4);
}
/* "3 product usersheets will be built - one per product", above the button
   that does it. */
.builder .bnbuild {
  margin-top: var(--s4); font-size: var(--t-meta); color: var(--port-blue);
}
.builder .bnbuild + .bfoot { margin-top: var(--s2); }
/* Sized to match the document card's Open button EXACTLY, since the two sit
   one above the other. The previous attempt at this only got close - 13px on a
   2px border against 10.5px on a 1px one - so Build still read as the heavier
   control (Christian, 3 Sep). */
.builder .bfoot .btn {
  min-height: var(--btn-h-sm); padding: 0 var(--s4);
  font-size: var(--t-micro); letter-spacing: var(--btn-ls); border-width: 1px;
}
/* The secondary action beside Build: the same button, in outline. Two solid
   orange buttons side by side would give equal weight to "carry on filling
   this in" and "produce the document". */
.btn.ghost { background: transparent; color: var(--port-blue);
             border-color: var(--rule); }
.btn.ghost:hover { border-color: var(--alert-orange); color: var(--alert-orange); }
/* A PDF this machine cannot make. The link stays readable and keeps its
   reason, rather than vanishing and leaving somebody to wonder. */
.btn.pdfdl.off, .btn.pdfdl.off:hover {
  color: var(--ink-faint); border-color: var(--rule); cursor: not-allowed;
}
.pdfmsg { flex: 1 0 100%; margin-top: var(--s2); font-size: var(--t-meta);
          color: var(--err); }
.builder .bstatus { font-size: var(--t-meta); color: var(--ink-soft); }
@media (max-width: 720px) {
  .builder .bgrid { grid-template-columns: minmax(0, 1fr); }
  .builder .bfield.wide { grid-column: auto; }
}

/* ------------------------------------------------- folded product records
   Past four, product records fold to a line each. A sixteen-product booklet
   otherwise produced sixteen full cards and buried the generated document -
   the one card the reader is looking for - under a column of near-identical
   fabric strings.

   <details>, so it works without JavaScript, is keyboard-operable for free,
   and a browser's own find-in-page can still open it. */
.foldhead {
  font-family: var(--f-display); font-size: var(--t-label);
  letter-spacing: var(--t-label-ls); text-transform: uppercase;
  color: var(--ink-soft); margin: var(--s3) 0 var(--s2);
}
.fold { border-bottom: 1px solid var(--rule-soft); }
.fold summary {
  display: flex; gap: var(--s2); align-items: baseline; cursor: pointer;
  padding: var(--s2) var(--s2) var(--s2) 0; font-size: 13px;
  list-style: none;
}
.fold summary::-webkit-details-marker { display: none; }
.fold summary:hover { color: var(--port-blue); }
.fold summary:focus-visible { outline: 2px solid var(--port-blue); outline-offset: 2px; }
.fold summary .code {
  font-family: var(--f-display); color: var(--alert-orange); letter-spacing: .05em;
}
.fold summary .nm { color: var(--ink); }
.fold .card { margin: 0 0 var(--s3); }

/* --- the certification block, collapsed (1 Sep). It was the tallest thing in
   the transcript on every product answer. The summary keeps what a reader must
   not miss - how many standards, and whether any class varies with size - and
   a product whose class DOES vary opens by default, because that is the one
   case where the detail is the point. */
.certblock > summary {
  display: flex; align-items: baseline; gap: var(--s3); cursor: pointer;
  list-style: none; padding-bottom: var(--s3); position: relative;
}
.certblock > summary::-webkit-details-marker { display: none; }
.certblock > summary::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; border-radius: 1px; background: var(--rule);
}
/* The affordance. A block that collapses has to look like one before it is
   clicked, and the count alone was not saying so.
   ONE CHEVRON FOR EVERY <details> IN THE APP (WP4): certblock had this SVG,
   .fold drew a rotating "›" glyph, and .compare had no affordance at all -
   three answers to the same question. All three now share this rule and the
   same markup (see certBlock/compareTable/renderSources in app.js). */
.certblock > summary .chev, .compare > summary .chev, .fold summary .chev,
.hrow > summary .chev {
  display: flex; align-self: center; flex-shrink: 0;
  color: var(--alert-orange);
  transition: transform var(--dur-fast) var(--ease-out);
}
.certblock > summary .chev svg, .compare > summary .chev svg,
.fold summary .chev svg, .hrow > summary .chev svg {
  width: 12px; height: 12px; display: block;
}
.certblock[open] > summary .chev, .compare[open] > summary .chev,
.fold[open] summary .chev, .hrow[open] > summary .chev { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) {
  .certblock > summary .chev, .compare > summary .chev, .fold summary .chev,
  .hrow > summary .chev { transition: none; }
}
.certblock > summary h3 {
  font-family: var(--f-display); font-weight: 400; font-size: var(--t-h2);
  letter-spacing: var(--t-h2-ls); text-transform: uppercase;
  color: var(--alert-orange); margin: 0;
}
.certblock > summary .cnt { font-size: var(--t-meta); color: var(--ink-soft); }
/* The one thing that must survive being collapsed. */
.certblock > summary .varies {
  /* Body face DOES have a 700 - a declared 600 was already rendering as it. */
  font-family: var(--f-body); font-weight: 700; font-size: var(--t-meta);
  color: var(--alert-orange); border: 1px solid var(--alert-orange);
  padding: 1px 6px; border-radius: var(--r-sm);
}
.certblock > summary .code { margin-left: auto; }
.certblock > summary:hover h3 { text-decoration: underline; text-underline-offset: 3px; }
.certblock > summary:focus-visible { outline: 2px solid var(--port-blue); outline-offset: 2px; }
.certblock:not([open]) > summary { padding-bottom: 0; }
.certblock:not([open]) > summary::after { display: none; }
